v1.0.10: Android TV D-pad navigation + new app icons
- Added tabindex to video cards for D-pad focus - Auto-detect TV mode and auto-focus first card - Enhanced red glow focus styles for TV viewing distance - Regenerated Android launcher icons with StreamFlix branding
|
Before Width: | Height: | Size: 543 B After Width: | Height: | Size: 543 B |
1
backend/static/assets/download-m6ZKmHFf.css
Normal file
|
Before Width: | Height: | Size: 445 B After Width: | Height: | Size: 445 B |
|
|
@ -1,18 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#1a1a2e"/>
|
|
||||||
<stop offset="100%" style="stop-color:#0f0f1a"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="playGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#e50914"/>
|
|
||||||
<stop offset="100%" style="stop-color:#b2070f"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<!-- Background -->
|
|
||||||
<rect width="512" height="512" rx="100" fill="url(#bgGrad)"/>
|
|
||||||
<!-- Play circle -->
|
|
||||||
<circle cx="256" cy="256" r="160" fill="url(#playGrad)"/>
|
|
||||||
<!-- Play triangle -->
|
|
||||||
<polygon points="210,170 210,342 370,256" fill="#141414"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 744 B |
|
Before Width: | Height: | Size: 615 B After Width: | Height: | Size: 615 B |
|
|
@ -1 +0,0 @@
|
||||||
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};
|
|
||||||
|
|
@ -6,8 +6,7 @@
|
||||||
<meta name="viewport"
|
<meta name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>StreamFlix - Download App</title>
|
<title>StreamFlix - Download App</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/logo-DuxtXB_R.svg">
|
||||||
<link rel="stylesheet" href="/styles/index.css">
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
|
@ -197,6 +196,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/download-m6ZKmHFf.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -204,7 +204,7 @@
|
||||||
<a href="/" class="back-link">← Back to StreamFlix</a>
|
<a href="/" class="back-link">← Back to StreamFlix</a>
|
||||||
|
|
||||||
<div class="download-container">
|
<div class="download-container">
|
||||||
<img src="/assets/logo.svg" alt="StreamFlix Logo" class="logo-hero">
|
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMDAgNjAiPgogIDxkZWZzPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJnb2xkR3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdHlsZT0ic3RvcC1jb2xvcjojZTUwOTE0Ii8+CiAgICAgIDxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6I2IyMDcwZiIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPCEtLSBQbGF5IGJ1dHRvbiBpY29uIC0tPgogIDxjaXJjbGUgY3g9IjI1IiBjeT0iMzAiIHI9IjIyIiBmaWxsPSJ1cmwoI2dvbGRHcmFkaWVudCkiLz4KICA8cG9seWdvbiBwb2ludHM9IjIwLDIwIDIwLDQwIDM4LDMwIiBmaWxsPSIjMTQxNDE0Ii8+CiAgPCEtLSBUZXh0IC0tPgogIDx0ZXh0IHg9IjU1IiB5PSI0MCIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjI4IiBmb250LXdlaWdodD0iYm9sZCIgZmlsbD0iI2ZmZmZmZiI+U3RyZWFtPHRzcGFuIGZpbGw9InVybCgjZ29sZEdyYWRpZW50KSI+RmxpeDwvdHNwYW4+PC90ZXh0Pgo8L3N2Zz4K" alt="StreamFlix Logo" class="logo-hero">
|
||||||
|
|
||||||
<h1>Download StreamFlix</h1>
|
<h1>Download StreamFlix</h1>
|
||||||
<p class="subtitle">Experience cinema-quality streaming on all your devices. Ad-free, high performance, and
|
<p class="subtitle">Experience cinema-quality streaming on all your devices. Ad-free, high performance, and
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
<meta name="referrer" content="no-referrer">
|
<meta name="referrer" content="no-referrer">
|
||||||
|
|
||||||
<!-- Icons -->
|
<!-- Icons -->
|
||||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon-D7BKdTu2.svg">
|
||||||
<link rel="apple-touch-icon" href="assets/apple-touch-icon.svg">
|
<link rel="apple-touch-icon" href="/assets/apple-touch-icon-CmxMqamG.svg">
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||||
|
|
@ -44,16 +44,7 @@
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"@capacitor/status-bar": "/js/capacitor-mock.js",
|
|
||||||
"@capacitor/haptics": "/js/capacitor-mock.js",
|
|
||||||
"artplayer": "https://esm.sh/artplayer@5.1.7",
|
|
||||||
"hls.js": "https://esm.sh/hls.js@1.5.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -218,13 +209,25 @@
|
||||||
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
||||||
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@capacitor/status-bar": "/js/capacitor-mock.js",
|
||||||
|
"@capacitor/haptics": "/js/capacitor-mock.js",
|
||||||
|
"artplayer": "https://esm.sh/artplayer@5.1.7",
|
||||||
|
"hls.js": "https://esm.sh/hls.js@1.5.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module" crossorigin src="/assets/main-BGz66_54.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/keyboard-nav-CjQOo0Sk.js">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-background-light dark:bg-background-dark text-white font-display overflow-x-hidden antialiased">
|
<body class="bg-background-light dark:bg-background-dark text-white font-display overflow-x-hidden antialiased">
|
||||||
|
|
||||||
<!-- Splash Screen -->
|
<!-- Splash Screen -->
|
||||||
<div id="splash-screen">
|
<div id="splash-screen">
|
||||||
<img src="/assets/logo.svg" alt="StreamFlix" class="splash-logo">
|
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMDAgNjAiPgogIDxkZWZzPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJnb2xkR3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdHlsZT0ic3RvcC1jb2xvcjojZTUwOTE0Ii8+CiAgICAgIDxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6I2IyMDcwZiIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPCEtLSBQbGF5IGJ1dHRvbiBpY29uIC0tPgogIDxjaXJjbGUgY3g9IjI1IiBjeT0iMzAiIHI9IjIyIiBmaWxsPSJ1cmwoI2dvbGRHcmFkaWVudCkiLz4KICA8cG9seWdvbiBwb2ludHM9IjIwLDIwIDIwLDQwIDM4LDMwIiBmaWxsPSIjMTQxNDE0Ii8+CiAgPCEtLSBUZXh0IC0tPgogIDx0ZXh0IHg9IjU1IiB5PSI0MCIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjI4IiBmb250LXdlaWdodD0iYm9sZCIgZmlsbD0iI2ZmZmZmZiI+U3RyZWFtPHRzcGFuIGZpbGw9InVybCgjZ29sZEdyYWRpZW50KSI+RmxpeDwvdHNwYW4+PC90ZXh0Pgo8L3N2Zz4K" alt="StreamFlix" class="splash-logo">
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<div id="loading-bar"></div>
|
<div id="loading-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,7 +244,7 @@
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<a class="flex items-center gap-2 hover:opacity-90 transition-opacity" href="/">
|
<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">
|
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMDAgNjAiPgogIDxkZWZzPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJnb2xkR3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdHlsZT0ic3RvcC1jb2xvcjojZTUwOTE0Ii8+CiAgICAgIDxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6I2IyMDcwZiIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPCEtLSBQbGF5IGJ1dHRvbiBpY29uIC0tPgogIDxjaXJjbGUgY3g9IjI1IiBjeT0iMzAiIHI9IjIyIiBmaWxsPSJ1cmwoI2dvbGRHcmFkaWVudCkiLz4KICA8cG9seWdvbiBwb2ludHM9IjIwLDIwIDIwLDQwIDM4LDMwIiBmaWxsPSIjMTQxNDE0Ii8+CiAgPCEtLSBUZXh0IC0tPgogIDx0ZXh0IHg9IjU1IiB5PSI0MCIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjI4IiBmb250LXdlaWdodD0iYm9sZCIgZmlsbD0iI2ZmZmZmZiI+U3RyZWFtPHRzcGFuIGZpbGw9InVybCgjZ29sZEdyYWRpZW50KSI+RmxpeDwvdHNwYW4+PC90ZXh0Pgo8L3N2Zz4K" alt="StreamFlix" class="h-8 md:h-10">
|
||||||
</a>
|
</a>
|
||||||
<!-- Desktop Nav Links -->
|
<!-- Desktop Nav Links -->
|
||||||
<nav class="hidden md:flex items-center gap-6" id="mainNav">
|
<nav class="hidden md:flex items-center gap-6" id="mainNav">
|
||||||
|
|
@ -464,9 +467,6 @@
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/js/history-service.js"></script>
|
<script src="/js/history-service.js"></script>
|
||||||
<script type="module" src="/scripts/search.js"></script>
|
|
||||||
<script type="module" src="/scripts/category-system.js"></script>
|
|
||||||
<script type="module" src="/scripts/main.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,431 +0,0 @@
|
||||||
/**
|
|
||||||
* StreamFlow - API Client
|
|
||||||
* Handles all communication with the backend
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Hardcode API_BASE to ensure Android App works correctly
|
|
||||||
const API_BASE = '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
|
|
||||||
* @param {string} quality - Optional quality preference (e.g., "1080p")
|
|
||||||
* @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',
|
|
||||||
...authHeaders
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ url, quality })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Extraction failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.qualities;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all videos
|
|
||||||
* @param {Object} options - Query options
|
|
||||||
* @returns {Promise<Array>} List of videos
|
|
||||||
*/
|
|
||||||
async listVideos({ skip = 0, limit = 50, category = null } = {}) {
|
|
||||||
let url = `${API_BASE}/videos?skip=${skip}&limit=${limit}`;
|
|
||||||
if (category && category !== 'all') {
|
|
||||||
url += `&category=${encodeURIComponent(category)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a video to the library
|
|
||||||
* @param {Object} video - Video data
|
|
||||||
* @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',
|
|
||||||
...authHeaders
|
|
||||||
},
|
|
||||||
body: JSON.stringify(video)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Failed to add video');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a video from the library
|
|
||||||
* @param {number} id - Video ID
|
|
||||||
*/
|
|
||||||
async deleteVideo(id) {
|
|
||||||
const path = `/api/videos/${id}`;
|
|
||||||
const authHeaders = await this.signRequest(path, 'DELETE');
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/videos/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: authHeaders
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to delete video');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search videos by title
|
|
||||||
* @param {string} query - Search query
|
|
||||||
* @param {number} limit - Max results
|
|
||||||
* @returns {Promise<Array>} Search results
|
|
||||||
*/
|
|
||||||
async searchVideos(query, limit = 20) {
|
|
||||||
const 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check API health
|
|
||||||
* @returns {Promise<Object>} Health status
|
|
||||||
*/
|
|
||||||
async health() {
|
|
||||||
const response = await fetch(`${API_BASE}/health`);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// RoPhim Integration Methods
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get RoPhim movie catalog
|
|
||||||
* @param {Object} options - Query options
|
|
||||||
* @returns {Promise<Object>} Catalog with movies
|
|
||||||
*/
|
|
||||||
async getRophimCatalog({ category = null, country = null, genre = null, page = 1, limit = 24, sort = 'modified' } = {}) {
|
|
||||||
let url = `${API_BASE}/rophim/catalog?page=${page}&limit=${limit}&sort=${sort}`;
|
|
||||||
if (category) url += `&category=${encodeURIComponent(category)}`;
|
|
||||||
if (country) url += `&country=${encodeURIComponent(country)}`;
|
|
||||||
if (genre) url += `&genre=${encodeURIComponent(genre)}`;
|
|
||||||
|
|
||||||
const 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get curated homepage sections (Top Rated, New Releases, by Genre)
|
|
||||||
* @returns {Promise<Object>} Sections with movies sorted by rating
|
|
||||||
*/
|
|
||||||
async getCuratedSections() {
|
|
||||||
const 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search movies on RoPhim
|
|
||||||
* @param {string} query - Search query
|
|
||||||
* @param {number} limit - Max results
|
|
||||||
* @returns {Promise<Object>} Search results
|
|
||||||
*/
|
|
||||||
async searchRophim(query, limit = 20) {
|
|
||||||
const 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get dynamic homepage sections (Genres/Countries)
|
|
||||||
* @param {number} page - Page number
|
|
||||||
* @returns {Promise<Object>} Sections
|
|
||||||
*/
|
|
||||||
async getHomeSections(page = 2, view = 'home') {
|
|
||||||
const 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get movie details from RoPhim
|
|
||||||
* @param {string} slug - Movie slug
|
|
||||||
* @returns {Promise<Object>} Movie details
|
|
||||||
*/
|
|
||||||
async getRophimMovie(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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get video stream URL from RoPhim
|
|
||||||
* @param {string} slug - Movie slug
|
|
||||||
* @param {number} episode - Episode number (default: 1)
|
|
||||||
* @returns {Promise<Object>} Stream URL
|
|
||||||
*/
|
|
||||||
async getRophimStream(slug, episode = 1) {
|
|
||||||
const 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}`,
|
|
||||||
{ headers: authHeaders }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to get stream');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get video stream URL from PhimMoiChill using source URL or slug
|
|
||||||
* This method extracts direct m3u8 from JWPlayer
|
|
||||||
* @param {string} sourceUrl - Full source URL (e.g., https://royalcanalbikehire.ie/phim/movie-name)
|
|
||||||
* @param {string} slug - Movie slug (optional, extracted from URL if not provided)
|
|
||||||
* @param {number} episode - Episode number (default: 1)
|
|
||||||
* @param {number} server - Server index (0=VIP1 m3u8, 1=VIP2 embed)
|
|
||||||
* @returns {Promise<Object>} Stream URL
|
|
||||||
*/
|
|
||||||
async getRophimStreamByUrl(sourceUrl, slug = '', episode = 1, server = 0) {
|
|
||||||
const 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',
|
|
||||||
...authHeaders
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ source_url: sourceUrl, slug: slug || '', episode, server })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Failed to get stream');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
/**
|
|
||||||
* Category System for PhimMoiChill Themed Sections
|
|
||||||
* Provides functionality to load and render categorized content
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load themed category sections from PhimMoiChill
|
|
||||||
*/
|
|
||||||
async function loadCategories() {
|
|
||||||
try {
|
|
||||||
console.log('📂 Loading themed categories...');
|
|
||||||
const response = await fetch('/api/rophim/categories/all');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data && data.categories) {
|
|
||||||
console.log(`✓ Loaded ${Object.keys(data.categories).length} category sections`);
|
|
||||||
return data.categories;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading categories:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create ranking badge for Top 10
|
|
||||||
*/
|
|
||||||
function createRankingBadge(rank) {
|
|
||||||
const badge = document.createElement('div');
|
|
||||||
badge.className = 'video-card__ranking';
|
|
||||||
|
|
||||||
// Add specific class for top 3 (gold, silver, bronze)
|
|
||||||
if (rank <= 3) {
|
|
||||||
badge.classList.add(`video-card__ranking--${rank}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
badge.textContent = `#${rank}`;
|
|
||||||
return badge;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create quality/category badge (NEW, HOT, CINEMA, etc.)
|
|
||||||
*/
|
|
||||||
function createQualityBadge(badgeText) {
|
|
||||||
if (!badgeText) return null;
|
|
||||||
|
|
||||||
const badge = document.createElement('div');
|
|
||||||
badge.className = 'video-card__badge';
|
|
||||||
|
|
||||||
// Determine badge style based on text
|
|
||||||
const text = badgeText.toUpperCase();
|
|
||||||
if (text.includes('HOT')) {
|
|
||||||
badge.classList.add('video-card__badge--hot');
|
|
||||||
} else if (text.includes('NEW')) {
|
|
||||||
badge.classList.add('video-card__badge--new');
|
|
||||||
} else if (text.includes('CINEMA')) {
|
|
||||||
badge.classList.add('video-card__badge--cinema');
|
|
||||||
} else if (text.includes('FULL')) {
|
|
||||||
badge.classList.add('video-card__badge--full');
|
|
||||||
}
|
|
||||||
|
|
||||||
badge.textContent = text;
|
|
||||||
return badge;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enhance video card with badges
|
|
||||||
*/
|
|
||||||
function enhanceVideoCardWithBadges(card, video) {
|
|
||||||
if (!card) return card;
|
|
||||||
|
|
||||||
const container = card.querySelector('.video-card__container');
|
|
||||||
if (!container) return card;
|
|
||||||
|
|
||||||
// Add quality badge if present
|
|
||||||
if (video.badge) {
|
|
||||||
const badge = createQualityBadge(video.badge);
|
|
||||||
if (badge) {
|
|
||||||
container.appendChild(badge);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add ranking badge if present (for Top 10)
|
|
||||||
if (video.ranking) {
|
|
||||||
const rankBadge = createRankingBadge(video.ranking);
|
|
||||||
container.appendChild(rankBadge);
|
|
||||||
}
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions for use in main.js
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.categorySystem = {
|
|
||||||
loadCategories,
|
|
||||||
createRankingBadge,
|
|
||||||
createQualityBadge,
|
|
||||||
enhanceVideoCardWithBadges
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,399 +0,0 @@
|
||||||
/**
|
|
||||||
* KV-Stream - Hero Billboard Component
|
|
||||||
* Modern Apple TV+ / Netflix inspired hero section
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a modern hero section with featured content
|
|
||||||
* @param {Object|Array<Object>} featuredItems - Featured video object or array
|
|
||||||
* @param {Function} onPlay - Callback when play is clicked
|
|
||||||
* @param {Function} onInfo - Callback when more info is clicked
|
|
||||||
* @param {string} modifier - Optional CSS class for variants
|
|
||||||
* @returns {HTMLElement} Hero section element
|
|
||||||
*/
|
|
||||||
export function createHeroSection(featuredItems, onPlay, onInfo, modifier = '') {
|
|
||||||
const hero = document.createElement('section');
|
|
||||||
hero.className = `hero-billboard ${modifier}`;
|
|
||||||
hero.id = 'heroSection';
|
|
||||||
|
|
||||||
// Normalize input to array
|
|
||||||
const items = Array.isArray(featuredItems) ? featuredItems : [featuredItems];
|
|
||||||
|
|
||||||
if (items.length === 0 || !items[0]) {
|
|
||||||
return hero;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get first featured item for display
|
|
||||||
const featured = items[0];
|
|
||||||
const backdropUrl = featured.backdrop || featured.thumbnail || featured.poster_url || '';
|
|
||||||
const year = featured.year || new Date().getFullYear();
|
|
||||||
const rating = featured.rating ? `${featured.rating}★` : '';
|
|
||||||
const quality = featured.resolution || featured.quality || 'HD';
|
|
||||||
const genre = featured.genre || featured.category || '';
|
|
||||||
const duration = featured.duration || '';
|
|
||||||
|
|
||||||
// Build meta items
|
|
||||||
const metaItems = [quality, year, genre, duration, rating].filter(Boolean);
|
|
||||||
|
|
||||||
hero.innerHTML = `
|
|
||||||
<div class="hero-billboard__backdrop">
|
|
||||||
<img src="${backdropUrl}" alt="${featured.title}" loading="eager" />
|
|
||||||
<div class="hero-billboard__gradient"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-billboard__content">
|
|
||||||
<div class="hero-billboard__info">
|
|
||||||
<h1 class="hero-billboard__title">${featured.title}</h1>
|
|
||||||
|
|
||||||
<div class="hero-billboard__meta">
|
|
||||||
${metaItems.map((item, i) => `
|
|
||||||
<span class="hero-billboard__meta-item">${item}</span>
|
|
||||||
${i < metaItems.length - 1 ? '<span class="hero-billboard__meta-dot">•</span>' : ''}
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="hero-billboard__description">${featured.description || ''}</p>
|
|
||||||
|
|
||||||
<div class="hero-billboard__actions">
|
|
||||||
<button class="hero-billboard__btn hero-billboard__btn--primary" data-action="play" data-id="${featured.id}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
|
||||||
<path d="M8 5v14l11-7z"/>
|
|
||||||
</svg>
|
|
||||||
<span>Watch Now</span>
|
|
||||||
</button>
|
|
||||||
<button class="hero-billboard__btn hero-billboard__btn--secondary" data-action="info" data-id="${featured.id}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24">
|
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
|
||||||
<path d="M12 16v-4"></path>
|
|
||||||
<path d="M12 8h.01"></path>
|
|
||||||
</svg>
|
|
||||||
<span>More Info</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${items.length > 1 ? createSliderDots(items) : ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add styles
|
|
||||||
addHeroStyles();
|
|
||||||
|
|
||||||
// Setup slider if multiple items
|
|
||||||
if (items.length > 1) {
|
|
||||||
setupSlider(hero, items, onPlay, onInfo);
|
|
||||||
} else {
|
|
||||||
// Single item events
|
|
||||||
setupSingleItemEvents(hero, featured, onPlay, onInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return hero;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSliderDots(items) {
|
|
||||||
return `
|
|
||||||
<div class="hero-billboard__dots">
|
|
||||||
${items.map((_, i) => `
|
|
||||||
<button class="hero-billboard__dot ${i === 0 ? 'active' : ''}" data-index="${i}"></button>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSingleItemEvents(hero, featured, onPlay, onInfo) {
|
|
||||||
const playBtn = hero.querySelector('[data-action="play"]');
|
|
||||||
const infoBtn = hero.querySelector('[data-action="info"]');
|
|
||||||
|
|
||||||
if (playBtn) {
|
|
||||||
playBtn.addEventListener('click', () => onPlay && onPlay(featured));
|
|
||||||
}
|
|
||||||
if (infoBtn) {
|
|
||||||
infoBtn.addEventListener('click', () => onInfo && onInfo(featured));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSlider(hero, items, onPlay, onInfo) {
|
|
||||||
let currentIndex = 0;
|
|
||||||
let interval;
|
|
||||||
const dots = hero.querySelectorAll('.hero-billboard__dot');
|
|
||||||
|
|
||||||
const showSlide = (index) => {
|
|
||||||
if (index < 0) index = items.length - 1;
|
|
||||||
if (index >= items.length) index = 0;
|
|
||||||
currentIndex = index;
|
|
||||||
|
|
||||||
const featured = items[index];
|
|
||||||
const backdropUrl = featured.backdrop || featured.thumbnail || featured.poster_url || '';
|
|
||||||
|
|
||||||
// Update content
|
|
||||||
const backdrop = hero.querySelector('.hero-billboard__backdrop img');
|
|
||||||
const title = hero.querySelector('.hero-billboard__title');
|
|
||||||
const description = hero.querySelector('.hero-billboard__description');
|
|
||||||
const playBtn = hero.querySelector('[data-action="play"]');
|
|
||||||
const infoBtn = hero.querySelector('[data-action="info"]');
|
|
||||||
|
|
||||||
if (backdrop) backdrop.src = backdropUrl;
|
|
||||||
if (title) title.textContent = featured.title;
|
|
||||||
if (description) description.textContent = featured.description || '';
|
|
||||||
if (playBtn) playBtn.dataset.id = featured.id;
|
|
||||||
if (infoBtn) infoBtn.dataset.id = featured.id;
|
|
||||||
|
|
||||||
// Update dots
|
|
||||||
dots.forEach((dot, i) => dot.classList.toggle('active', i === index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const startAutoPlay = () => {
|
|
||||||
if (interval) clearInterval(interval);
|
|
||||||
interval = setInterval(() => showSlide(currentIndex + 1), 8000);
|
|
||||||
};
|
|
||||||
|
|
||||||
startAutoPlay();
|
|
||||||
|
|
||||||
// Dot click events
|
|
||||||
dots.forEach(dot => {
|
|
||||||
dot.addEventListener('click', () => {
|
|
||||||
const idx = parseInt(dot.dataset.index);
|
|
||||||
showSlide(idx);
|
|
||||||
startAutoPlay();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Button events
|
|
||||||
hero.addEventListener('click', (e) => {
|
|
||||||
const playBtn = e.target.closest('[data-action="play"]');
|
|
||||||
const infoBtn = e.target.closest('[data-action="info"]');
|
|
||||||
|
|
||||||
if (playBtn && onPlay) {
|
|
||||||
onPlay(items[currentIndex]);
|
|
||||||
} else if (infoBtn && onInfo) {
|
|
||||||
onInfo(items[currentIndex]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addHeroStyles() {
|
|
||||||
if (document.getElementById('hero-billboard-styles')) return;
|
|
||||||
|
|
||||||
const styles = document.createElement('style');
|
|
||||||
styles.id = 'hero-billboard-styles';
|
|
||||||
styles.textContent = `
|
|
||||||
.hero-billboard {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
/* Fluid height: scales with viewport, min 300px, max 85vh */
|
|
||||||
height: clamp(300px, 60vh, 85vh);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: clamp(10px, 2vw, 30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__backdrop img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: center 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__gradient {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
rgba(0, 0, 0, 0.95) 0%,
|
|
||||||
rgba(0, 0, 0, 0.7) 25%,
|
|
||||||
rgba(0, 0, 0, 0.3) 50%,
|
|
||||||
transparent 75%
|
|
||||||
),
|
|
||||||
linear-gradient(
|
|
||||||
to top,
|
|
||||||
rgba(0, 0, 0, 1) 0%,
|
|
||||||
rgba(0, 0, 0, 0.6) 15%,
|
|
||||||
transparent 50%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__content {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
/* Fluid padding: scales with viewport */
|
|
||||||
padding: 0 clamp(20px, 5vw, 80px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__info {
|
|
||||||
/* Fluid max-width: scales between 280px and 600px based on viewport */
|
|
||||||
max-width: clamp(280px, 40vw, 600px);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__title {
|
|
||||||
/* Fluid font-size: scales dynamically with screen */
|
|
||||||
font-size: clamp(1.5rem, 4vw, 3.5rem);
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
margin: 0 0 clamp(8px, 1.5vw, 20px) 0;
|
|
||||||
line-height: 1.15;
|
|
||||||
text-shadow: 0 2px 10px rgba(0,0,0,0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: clamp(4px, 0.8vw, 10px);
|
|
||||||
margin-bottom: clamp(8px, 1.5vw, 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__meta-item {
|
|
||||||
font-size: clamp(0.7rem, 1vw, 1rem);
|
|
||||||
color: rgba(255,255,255,0.9);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__meta-item:first-child {
|
|
||||||
background: #0071e3;
|
|
||||||
padding: clamp(2px, 0.4vw, 6px) clamp(6px, 0.8vw, 12px);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: clamp(0.65rem, 0.9vw, 0.85rem);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__meta-dot {
|
|
||||||
color: rgba(255,255,255,0.5);
|
|
||||||
font-size: clamp(0.6rem, 0.8vw, 0.85rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__description {
|
|
||||||
font-size: clamp(0.85rem, 1.1vw, 1.1rem);
|
|
||||||
color: rgba(255,255,255,0.8);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0 0 clamp(12px, 2vw, 28px) 0;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: clamp(8px, 1vw, 14px);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: clamp(6px, 0.6vw, 10px);
|
|
||||||
padding: clamp(10px, 1.2vw, 16px) clamp(16px, 2vw, 32px);
|
|
||||||
border: none;
|
|
||||||
border-radius: clamp(6px, 0.6vw, 10px);
|
|
||||||
font-size: clamp(0.85rem, 1vw, 1.1rem);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__btn--primary {
|
|
||||||
background: #fff;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__btn--primary:hover {
|
|
||||||
background: rgba(255,255,255,0.9);
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__btn--secondary {
|
|
||||||
background: rgba(255,255,255,0.15);
|
|
||||||
color: #fff;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__btn--secondary:hover {
|
|
||||||
background: rgba(255,255,255,0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__btn svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: clamp(18px, 1.5vw, 24px);
|
|
||||||
height: clamp(18px, 1.5vw, 24px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__dots {
|
|
||||||
position: absolute;
|
|
||||||
bottom: clamp(15px, 3vh, 35px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
gap: clamp(5px, 0.6vw, 10px);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__dot {
|
|
||||||
width: clamp(6px, 0.6vw, 10px);
|
|
||||||
height: clamp(6px, 0.6vw, 10px);
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: rgba(255,255,255,0.4);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__dot.active {
|
|
||||||
background: #fff;
|
|
||||||
width: clamp(16px, 2vw, 28px);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__dot:hover {
|
|
||||||
background: rgba(255,255,255,0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Small variant for category pages */
|
|
||||||
.hero-billboard.hero--small {
|
|
||||||
height: clamp(200px, 35vh, 400px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile-specific adjustments (small screens need content at bottom) */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.hero-billboard__content {
|
|
||||||
align-items: flex-end;
|
|
||||||
padding-bottom: clamp(50px, 10vh, 80px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__info {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__description {
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-billboard__btn {
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(styles);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initHeroCarousel(container, featuredVideos, onPlay, onInfo) {
|
|
||||||
if (!featuredVideos || featuredVideos.length === 0) return;
|
|
||||||
|
|
||||||
const hero = createHeroSection(featuredVideos, onPlay, onInfo);
|
|
||||||
container.innerHTML = '';
|
|
||||||
container.appendChild(hero);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
/**
|
|
||||||
* 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';
|
|
||||||
modal.id = `modal-${video.id}`;
|
|
||||||
|
|
||||||
const backdropUrl = video.backdrop || video.thumbnail;
|
|
||||||
const isSeries = video.type === 'series' || video.category?.toLowerCase() === 'series';
|
|
||||||
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="modal__backdrop"></div>
|
|
||||||
<div class="modal__container">
|
|
||||||
<button class="modal__close" aria-label="Close">
|
|
||||||
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="modal__header">
|
|
||||||
<div class="modal__header-video">
|
|
||||||
<img src="${backdropUrl}" alt="${video.title}" class="modal__header-img">
|
|
||||||
${video.preview_url ? `
|
|
||||||
<video class="modal__header-preview" muted playsinline loop>
|
|
||||||
<source src="${video.preview_url}" type="video/mp4">
|
|
||||||
</video>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="modal__header-vignette"></div>
|
|
||||||
<div class="modal__header-content">
|
|
||||||
<h2 class="modal__title">${video.title}</h2>
|
|
||||||
<div class="modal__actions">
|
|
||||||
<button class="modal__btn modal__btn--primary" data-action="play">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
<span>Play</span>
|
|
||||||
</button>
|
|
||||||
<button class="modal__btn modal__btn--round" data-action="add" title="Add to My List">
|
|
||||||
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" width="24" height="24"><path d="M12 5v14m-7-7h14" stroke-width="2" stroke-linecap="round"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="modal__btn modal__btn--round" data-action="like" title="I like this">
|
|
||||||
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" width="24" height="24"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" stroke-width="2" stroke-linecap="round"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal__body">
|
|
||||||
<div class="modal__info-grid">
|
|
||||||
<div class="modal__info-main">
|
|
||||||
<div class="modal__metadata">
|
|
||||||
<span class="modal__match">${video.matchScore || 95}% Match</span>
|
|
||||||
<span class="modal__year">${video.releaseYear || video.year || 2024}</span>
|
|
||||||
<span class="modal__age">${video.maturityRating || '13+'}</span>
|
|
||||||
<span class="modal__duration">${video.duration ? Math.floor(video.duration / 3600) + 'h ' + Math.floor((video.duration % 3600) / 60) + 'm' : '2h 15m'}</span>
|
|
||||||
<span class="modal__quality">${video.quality || 'HD'}</span>
|
|
||||||
</div>
|
|
||||||
<p class="modal__description">${video.description || 'No description available for this title.'}</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal__info-side">
|
|
||||||
${video.cast && video.cast.length && video.cast[0] !== 'Unknown' ? `
|
|
||||||
<div class="modal__tags">
|
|
||||||
<span class="modal__label">Cast:</span>
|
|
||||||
<span class="modal__value">${video.cast.join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<div class="modal__tags">
|
|
||||||
<span class="modal__label">Genres:</span>
|
|
||||||
<span class="modal__value">${video.genres ? video.genres.join(', ') : video.category || 'Movies'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${video.director && video.director !== 'Unknown' ? `
|
|
||||||
<div class="modal__tags">
|
|
||||||
<span class="modal__label">Director:</span>
|
|
||||||
<span class="modal__value">${video.director}</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${video.country && video.country !== 'International' ? `
|
|
||||||
<div class="modal__tags">
|
|
||||||
<span class="modal__label">Country:</span>
|
|
||||||
<span class="modal__value">${video.country}</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${isSeries && video.episodes && video.episodes.length > 0 ? `
|
|
||||||
<div class="modal__episodes">
|
|
||||||
<div class="modal__section-header">
|
|
||||||
<h3 class="modal__section-title">Episodes</h3>
|
|
||||||
<span class="modal__episode-count">${video.episodes.length} Episodes</span>
|
|
||||||
</div>
|
|
||||||
<div class="modal__episodes-list">
|
|
||||||
${video.episodes.map(ep => `
|
|
||||||
<div class="episode-row" data-episode-url="${ep.url}">
|
|
||||||
<div class="episode-row__number">${ep.number}</div>
|
|
||||||
<div class="episode-row__img">
|
|
||||||
<img src="${video.backdrop || video.thumbnail}" alt="Episode ${ep.number}">
|
|
||||||
<div class="episode-row__play-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="episode-row__info">
|
|
||||||
<div class="episode-row__header">
|
|
||||||
<span class="episode-row__title">${ep.title || `Episode ${ep.number}`}</span>
|
|
||||||
<span class="episode-row__duration">${Math.floor(Math.random() * 20 + 40)}m</span>
|
|
||||||
</div>
|
|
||||||
<p class="episode-row__desc">${ep.description || (video.description || '').substring(0, 60)}...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${recommendations.length > 0 ? `
|
|
||||||
<div class="modal__recommendations">
|
|
||||||
<h3 class="modal__section-title">More Like This</h3>
|
|
||||||
<div class="recommendations-grid">
|
|
||||||
${recommendations.map(rec => `
|
|
||||||
<div class="recommendation-card" data-video-id="${rec.id}">
|
|
||||||
<div class="recommendation-card__img-wrapper">
|
|
||||||
<img src="${rec.thumbnail}" alt="${rec.title}">
|
|
||||||
<div class="recommendation-card__play">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="recommendation-card__content">
|
|
||||||
<h4 class="recommendation-card__title">${rec.title}</h4>
|
|
||||||
<div class="recommendation-card__meta">
|
|
||||||
<span class="modal__match">${rec.matchScore || 90}% Match</span>
|
|
||||||
<span class="modal__age">${rec.maturityRating || '13+'}</span>
|
|
||||||
<span class="modal__year">${rec.year || 2024}</span>
|
|
||||||
</div>
|
|
||||||
<p class="recommendation-card__desc">${(rec.description || 'No description').substring(0, 80)}${rec.description && rec.description.length > 80 ? '...' : ''}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Event Listeners
|
|
||||||
modal.querySelector('.modal__close').addEventListener('click', () => {
|
|
||||||
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');
|
|
||||||
const headerImg = modal.querySelector('.modal__header-img');
|
|
||||||
if (headerVideo) {
|
|
||||||
setTimeout(() => {
|
|
||||||
headerVideo.play().then(() => {
|
|
||||||
headerImg.style.opacity = '0';
|
|
||||||
headerVideo.style.opacity = '1';
|
|
||||||
}).catch(e => console.log('Autoplay failed', e));
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recommendation card clicks
|
|
||||||
modal.querySelectorAll('.recommendation-card').forEach(card => {
|
|
||||||
card.addEventListener('click', () => {
|
|
||||||
const vidId = card.dataset.videoId;
|
|
||||||
const targetVid = recommendations.find(r => r.id == vidId);
|
|
||||||
if (targetVid) {
|
|
||||||
// In a real app, we might navigate or open another modal
|
|
||||||
onPlay(targetVid);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Episode row clicks
|
|
||||||
modal.querySelectorAll('.episode-row').forEach(row => {
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
const url = row.dataset.episodeUrl;
|
|
||||||
if (url) {
|
|
||||||
// Create a temporary video object for the episode
|
|
||||||
const episodeTitle = row.querySelector('.episode-row__title').textContent;
|
|
||||||
const episodeVideo = {
|
|
||||||
...video,
|
|
||||||
source_url: url,
|
|
||||||
title: `${video.title}: ${episodeTitle}`,
|
|
||||||
isEpisode: true
|
|
||||||
};
|
|
||||||
onPlay(episodeVideo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return modal;
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { api } from '../api.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Netflix 2025 "New & Hot" Feed Component
|
|
||||||
* Optimized for mobile vertical scrolling
|
|
||||||
*/
|
|
||||||
export function createNewAndHotItem(video) {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'new-hot-item';
|
|
||||||
|
|
||||||
// Random date for demo
|
|
||||||
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
|
|
||||||
const month = months[Math.floor(Math.random() * 12)];
|
|
||||||
const day = Math.floor(Math.random() * 28) + 1;
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
<span class="new-hot-item__day">${day}</span>
|
|
||||||
</div>
|
|
||||||
<div class="new-hot-item__content">
|
|
||||||
<div class="new-hot-item__card">
|
|
||||||
<div class="new-hot-item__img-wrapper">
|
|
||||||
<img src="${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>
|
|
||||||
</div>
|
|
||||||
<div class="new-hot-item__details">
|
|
||||||
<div class="new-hot-item__header">
|
|
||||||
<h2 class="new-hot-item__title">${video.title}</h2>
|
|
||||||
<div class="new-hot-item__actions">
|
|
||||||
<button class="new-hot-item__btn">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="24" height="24"><path d="M15 10l5 5-5 5M4 4v7a4 4 0 0 0 4 4h12" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
<span>Remind Me</span>
|
|
||||||
</button>
|
|
||||||
<button class="new-hot-item__btn">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="24" height="24"><path d="M12 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z" stroke-width="1"/></svg>
|
|
||||||
<span>Info</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="new-hot-item__desc">${video.description || 'Watch now on Netflix.'}</p>
|
|
||||||
<div class="new-hot-item__tags">
|
|
||||||
${(video.genres || ['Exciting', 'Action', 'Netflix Original']).map(t => `<span class="new-hot-item__tag">${t}</span>`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderNewAndHotView(container, videos) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="new-hot-view">
|
|
||||||
<div class="new-hot-header">
|
|
||||||
<div class="new-hot-tabs">
|
|
||||||
<button class="new-hot-tab active">🍿 Coming Soon</button>
|
|
||||||
<button class="new-hot-tab">🔥 Everyone's Watching</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="new-hot-feed">
|
|
||||||
<!-- Items will be injected here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const feed = container.querySelector('.new-hot-feed');
|
|
||||||
videos.forEach(video => {
|
|
||||||
feed.appendChild(createNewAndHotItem(video));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
/**
|
|
||||||
* StreamFlow - Search Component
|
|
||||||
* Real-time search with 300ms debouncing
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { api } from '../api.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a debounced function
|
|
||||||
* @param {function} func - Function to debounce
|
|
||||||
* @param {number} wait - Wait time in ms
|
|
||||||
* @returns {function} Debounced function
|
|
||||||
*/
|
|
||||||
function debounce(func, wait) {
|
|
||||||
let timeout;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const later = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize search functionality
|
|
||||||
* @param {HTMLInputElement} inputEl - Search input element
|
|
||||||
* @param {HTMLElement} resultsEl - Search results container
|
|
||||||
* @param {function} onSelect - Callback when result is selected
|
|
||||||
*/
|
|
||||||
export function initSearch(inputEl, resultsEl, onSelect) {
|
|
||||||
if (!inputEl || !resultsEl) return;
|
|
||||||
|
|
||||||
const DEBOUNCE_DELAY = 300;
|
|
||||||
let currentQuery = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform search and update results
|
|
||||||
* @param {string} query - Search query
|
|
||||||
*/
|
|
||||||
async function performSearch(query) {
|
|
||||||
currentQuery = query;
|
|
||||||
|
|
||||||
if (!query || query.length < 2) {
|
|
||||||
resultsEl.classList.remove('active');
|
|
||||||
resultsEl.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use RoPhim search API instead of local database
|
|
||||||
const response = await api.searchRophim(query);
|
|
||||||
const results = response?.movies || [];
|
|
||||||
|
|
||||||
// Only update if query hasn't changed
|
|
||||||
if (query !== currentQuery) return;
|
|
||||||
|
|
||||||
if (results.length === 0) {
|
|
||||||
resultsEl.innerHTML = `
|
|
||||||
<div class="search__result" style="opacity: 0.5;">
|
|
||||||
<span>No results found for "${escapeHtml(query)}"</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
resultsEl.innerHTML = results.map(video => {
|
|
||||||
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>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Add click handlers - navigate to watch page
|
|
||||||
resultsEl.querySelectorAll('.search__result[data-video-slug]').forEach(el => {
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
const slug = el.dataset.videoSlug;
|
|
||||||
window.location.href = `/watch.html?id=${slug}&slug=${slug}`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resultsEl.classList.add('active');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search error:', error);
|
|
||||||
resultsEl.innerHTML = `
|
|
||||||
< div class="search__result" style = "color: var(--color-error);" >
|
|
||||||
<span>Search failed. Please try again.</span>
|
|
||||||
</div >
|
|
||||||
`;
|
|
||||||
resultsEl.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounced search handler
|
|
||||||
const debouncedSearch = debounce(performSearch, DEBOUNCE_DELAY);
|
|
||||||
|
|
||||||
// Input event handler
|
|
||||||
inputEl.addEventListener('input', (e) => {
|
|
||||||
debouncedSearch(e.target.value.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close results on click outside
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (inputEl && resultsEl && !inputEl.contains(e.target) && !resultsEl.contains(e.target)) {
|
|
||||||
resultsEl.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close on escape
|
|
||||||
inputEl.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
inputEl.blur();
|
|
||||||
resultsEl.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reopen on focus if there's a query
|
|
||||||
inputEl.addEventListener('focus', () => {
|
|
||||||
if (inputEl.value.trim().length >= 2) {
|
|
||||||
resultsEl.classList.add('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML special characters
|
|
||||||
* @param {string} str - Input string
|
|
||||||
* @returns {string} Escaped string
|
|
||||||
*/
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = str;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
/**
|
|
||||||
* StreamFlow - Toast Notification Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TOAST_DURATION = 4000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a toast notification
|
|
||||||
* @param {string} message - Toast message
|
|
||||||
* @param {string} type - Toast type: 'success', 'error', 'info'
|
|
||||||
*/
|
|
||||||
export function showToast(message, type = 'info') {
|
|
||||||
const container = document.getElementById('toastContainer');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `toast toast--${type}`;
|
|
||||||
toast.innerHTML = `
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
|
||||||
${getToastIcon(type)}
|
|
||||||
</svg>
|
|
||||||
<span>${escapeHtml(message)}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.appendChild(toast);
|
|
||||||
|
|
||||||
// Auto-remove after duration
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.animation = 'slideIn 0.3s ease reverse';
|
|
||||||
setTimeout(() => toast.remove(), 300);
|
|
||||||
}, TOAST_DURATION);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get icon SVG path for toast type
|
|
||||||
* @param {string} type - Toast type
|
|
||||||
* @returns {string} SVG path
|
|
||||||
*/
|
|
||||||
function getToastIcon(type) {
|
|
||||||
switch (type) {
|
|
||||||
case 'success':
|
|
||||||
return '<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>';
|
|
||||||
case 'error':
|
|
||||||
return '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>';
|
|
||||||
default:
|
|
||||||
return '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML special characters
|
|
||||||
* @param {string} str - Input string
|
|
||||||
* @returns {string} Escaped string
|
|
||||||
*/
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = str;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
import { api } from '../api.js';
|
|
||||||
import { imageCache } from '../services/imageCache.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if movie is newly released (within last 30 days or current year)
|
|
||||||
*/
|
|
||||||
function isNewRelease(video) {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
// Check if released this year
|
|
||||||
if (video.year === currentYear) return true;
|
|
||||||
|
|
||||||
// Check quality badge for "Mới" or "New" indicators
|
|
||||||
const quality = (video.quality || '').toLowerCase();
|
|
||||||
if (quality.includes('mới') || quality.includes('new')) return true;
|
|
||||||
|
|
||||||
// Check if movie was recently added (within 7 days)
|
|
||||||
if (video.modified?.time) {
|
|
||||||
const modifiedDate = new Date(video.modified.time);
|
|
||||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
if (modifiedDate > sevenDaysAgo) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect movie type based on episode count and quality
|
|
||||||
*/
|
|
||||||
function getMovieType(video) {
|
|
||||||
const quality = (video.quality || '').toLowerCase();
|
|
||||||
const episodeCount = video.episodes?.length || 0;
|
|
||||||
const category = (video.category || video.type || '').toLowerCase();
|
|
||||||
|
|
||||||
// Check for trailer
|
|
||||||
if (quality.includes('trailer') || category.includes('trailer')) {
|
|
||||||
return 'trailer';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for series (has episodes or is marked as series)
|
|
||||||
if (episodeCount > 1 || category.includes('series') || category.includes('phim-bo') ||
|
|
||||||
quality.includes('tập') || quality.includes('ep')) {
|
|
||||||
return 'series';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for animation
|
|
||||||
if (category.includes('hoathinh') || category.includes('animation') || category.includes('anime')) {
|
|
||||||
return 'animation';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to full movie
|
|
||||||
return 'movie';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get episode count text
|
|
||||||
*/
|
|
||||||
function getEpisodeText(video) {
|
|
||||||
const quality = video.quality || '';
|
|
||||||
// Check if quality contains episode info like "Tập 12" or "12/24"
|
|
||||||
const epMatch = quality.match(/(?:tập\s*)?(\d+)(?:\s*\/\s*(\d+))?/i);
|
|
||||||
if (epMatch) {
|
|
||||||
return quality; // Return as-is, it already contains episode info
|
|
||||||
}
|
|
||||||
|
|
||||||
const episodeCount = video.episodes?.length || 0;
|
|
||||||
if (episodeCount > 1) {
|
|
||||||
return `${episodeCount} Tập`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a video card element - PhimMoi Style
|
|
||||||
* @param {Object} video - Video data
|
|
||||||
* @param {function} onPlay - Callback when play is clicked
|
|
||||||
* @param {function} onInfo - Callback when more info is clicked
|
|
||||||
* @returns {HTMLElement} Video card element
|
|
||||||
*/
|
|
||||||
export function createVideoCard(video, onPlay, onInfo) {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'video-card';
|
|
||||||
card.dataset.videoId = video.id;
|
|
||||||
|
|
||||||
// PERFORMANCE: Use backend image proxy for faster loading (WebP + Resized)
|
|
||||||
// Use optimized sizes for mobile/desktop balance (quality vs speed)
|
|
||||||
const isMobile = window.innerWidth < 768;
|
|
||||||
const imageWidth = isMobile ? 180 : 200;
|
|
||||||
const originalThumbnail = video.thumbnail || '';
|
|
||||||
const thumbnail = api.getProxyUrl(originalThumbnail, imageWidth);
|
|
||||||
const year = video.year || new Date().getFullYear();
|
|
||||||
|
|
||||||
// Smart badge detection
|
|
||||||
const isNew = isNewRelease(video);
|
|
||||||
const movieType = getMovieType(video);
|
|
||||||
const episodeText = getEpisodeText(video);
|
|
||||||
|
|
||||||
// Quality badge (HD, FHD, 4K, CAM, etc.)
|
|
||||||
let qualityBadge = video.quality || 'HD';
|
|
||||||
// Clean up quality text - remove episode info if it exists
|
|
||||||
qualityBadge = qualityBadge.replace(/(?:tập\s*)?\d+(?:\s*\/\s*\d+)?/gi, '').trim() || 'HD';
|
|
||||||
if (qualityBadge.length > 6) qualityBadge = 'HD'; // Fallback if too long
|
|
||||||
|
|
||||||
// Numeric rating badge
|
|
||||||
const rating = parseFloat(video.rating || 0);
|
|
||||||
const isFresh = rating >= 7.0;
|
|
||||||
const ratingPercent = Math.round(rating * 10);
|
|
||||||
|
|
||||||
let numericRatingHTML = '';
|
|
||||||
if (rating > 0) {
|
|
||||||
numericRatingHTML = `
|
|
||||||
<div class="numeric-rating">
|
|
||||||
<span class="numeric-rating__score">${rating.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build rating badge HTML (Rotten Tomatoes style)
|
|
||||||
let tomatoBadgeHTML = '';
|
|
||||||
if (rating > 0) {
|
|
||||||
const tomatoIcon = isFresh ? '🍅' : '🥀';
|
|
||||||
tomatoBadgeHTML = `
|
|
||||||
<div class="tomato-badge ${isFresh ? 'tomato-badge--fresh' : 'tomato-badge--rotten'}">
|
|
||||||
<span class="tomato-badge__icon">${tomatoIcon}</span>
|
|
||||||
<span class="tomato-badge__score">${ratingPercent}%</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placeholder for loading state
|
|
||||||
const placeholderSvg = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 450"%3E%3Crect width="300" height="450" fill="%2314141c"/%3E%3C/svg%3E';
|
|
||||||
|
|
||||||
// Build tags HTML
|
|
||||||
let tagsHTML = '';
|
|
||||||
|
|
||||||
// NEW tag (top left)
|
|
||||||
if (isNew) {
|
|
||||||
tagsHTML += `<span class="video-tag video-tag--new">MỚI</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type tag (SERIES / PHIM LẺ)
|
|
||||||
if (movieType === 'trailer') {
|
|
||||||
tagsHTML += `<span class="video-tag video-tag--trailer">TRAILER</span>`;
|
|
||||||
} else if (movieType === 'series') {
|
|
||||||
tagsHTML += `<span class="video-tag video-tag--series">PHIM BỘ</span>`;
|
|
||||||
} else if (movieType === 'animation') {
|
|
||||||
tagsHTML += `<span class="video-tag video-tag--animation">HOẠT HÌNH</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="video-card__container">
|
|
||||||
<div class="video-card__poster">
|
|
||||||
<img src="${placeholderSvg}" data-src="${thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy" referrerpolicy="no-referrer" class="video-card__img" onerror="this.onerror=null;this.src='https://placehold.co/400x600/14141c/e5c07b?text=Movie'">
|
|
||||||
|
|
||||||
<!-- Top Left Tags -->
|
|
||||||
<div class="video-tags">
|
|
||||||
${tagsHTML}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom Right Info (Ratings & Quality) -->
|
|
||||||
<div class="card-meta-bottom-right">
|
|
||||||
${tomatoBadgeHTML}
|
|
||||||
${numericRatingHTML}
|
|
||||||
<span class="poster-badge">${qualityBadge}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom Left Info (Year & Episodes) -->
|
|
||||||
<div class="card-meta-bottom-left">
|
|
||||||
<span class="year-badge">${year}</span>
|
|
||||||
${episodeText ? `<span class="episode-badge">${episodeText}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Watch Progress Bar -->
|
|
||||||
${video.progress && video.progress.percentage > 0 ? `
|
|
||||||
<div class="video-card__progress">
|
|
||||||
<div class="video-card__progress-fill" style="width: ${video.progress.percentage}%"></div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<!-- Play overlay on hover -->
|
|
||||||
<div class="video-card__overlay">
|
|
||||||
<button class="video-card__play-btn" data-action="play" aria-label="Play">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="40" height="40">
|
|
||||||
<path d="M8 5v14l11-7z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Movie Title -->
|
|
||||||
<div class="video-card__title">
|
|
||||||
<span class="video-card__name">${escapeHtml(video.title)}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Lazy load image from cache when visible
|
|
||||||
const img = card.querySelector('.video-card__img');
|
|
||||||
if (img && thumbnail) {
|
|
||||||
// Use IntersectionObserver for lazy loading
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
// Load from cache
|
|
||||||
imageCache.getCachedImage(thumbnail).then(cachedUrl => {
|
|
||||||
img.src = cachedUrl;
|
|
||||||
img.classList.add('loaded');
|
|
||||||
}).catch(() => {
|
|
||||||
// Fallback to direct load
|
|
||||||
img.src = thumbnail;
|
|
||||||
img.onload = () => img.classList.add('loaded');
|
|
||||||
img.onerror = () => img.classList.add('loaded'); // Show placeholder if fails
|
|
||||||
});
|
|
||||||
observer.unobserve(img);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, {
|
|
||||||
rootMargin: '800px', // Start loading 800px before visible
|
|
||||||
threshold: 0
|
|
||||||
});
|
|
||||||
observer.observe(img);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event Listeners
|
|
||||||
card.querySelector('[data-action="play"]')?.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onPlay?.(video);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Default click behavior - play on any click
|
|
||||||
card.addEventListener('click', () => {
|
|
||||||
onPlay?.(video);
|
|
||||||
});
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = str;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
/**
|
|
||||||
* StreamFlow - Video Player Component
|
|
||||||
* ArtPlayer.js integration with custom skin
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Artplayer from 'artplayer';
|
|
||||||
|
|
||||||
// Player instance reference
|
|
||||||
let currentPlayer = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format duration for display
|
|
||||||
* @param {number} seconds - Duration in seconds
|
|
||||||
* @returns {string} Formatted duration
|
|
||||||
*/
|
|
||||||
function formatDuration(seconds) {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
return `${minutes}:${String(secs).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize video player
|
|
||||||
* @param {HTMLElement} container - Container element
|
|
||||||
* @param {Object} options - Player options
|
|
||||||
* @returns {Artplayer} Player instance
|
|
||||||
*/
|
|
||||||
export function initPlayer(container, options = {}) {
|
|
||||||
// Destroy existing player if any
|
|
||||||
destroyPlayer();
|
|
||||||
|
|
||||||
const {
|
|
||||||
url,
|
|
||||||
poster,
|
|
||||||
title,
|
|
||||||
autoplay = false,
|
|
||||||
qualities = []
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Build player config with enhanced buffering
|
|
||||||
const playerConfig = {
|
|
||||||
container,
|
|
||||||
url,
|
|
||||||
poster,
|
|
||||||
title,
|
|
||||||
volume: 0.7,
|
|
||||||
autoplay,
|
|
||||||
autoSize: false,
|
|
||||||
autoMini: true,
|
|
||||||
loop: false,
|
|
||||||
flip: true,
|
|
||||||
playbackRate: true,
|
|
||||||
aspectRatio: true,
|
|
||||||
screenshot: true,
|
|
||||||
setting: true,
|
|
||||||
hotkey: true,
|
|
||||||
pip: true,
|
|
||||||
mutex: true,
|
|
||||||
fullscreen: true,
|
|
||||||
fullscreenWeb: true,
|
|
||||||
miniProgressBar: true,
|
|
||||||
playsInline: true,
|
|
||||||
autoPlayback: true,
|
|
||||||
theme: '#f5c518', // Golden-yellow accent
|
|
||||||
lang: 'en',
|
|
||||||
moreVideoAttr: {
|
|
||||||
// crossOrigin: 'anonymous',
|
|
||||||
preload: 'auto',
|
|
||||||
},
|
|
||||||
airplay: true,
|
|
||||||
// HLS custom configuration for better buffering
|
|
||||||
customType: {
|
|
||||||
m3u8: function playM3u8(video, url, art) {
|
|
||||||
// Check if Android - prefer native HLS to avoid CORS/hls.js issues
|
|
||||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
if (isAndroid && video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
||||||
video.src = url;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
|
||||||
if (art.hls) {
|
|
||||||
art.hls.destroy();
|
|
||||||
}
|
|
||||||
const hls = new Hls({
|
|
||||||
// Buffer configuration for faster start
|
|
||||||
maxBufferLength: 30, // Max buffer in seconds
|
|
||||||
maxMaxBufferLength: 60, // Max buffer ceiling
|
|
||||||
maxBufferSize: 60 * 1000 * 1000, // Max buffer size (60MB)
|
|
||||||
maxBufferHole: 0.5, // Max gap in buffer
|
|
||||||
lowLatencyMode: false, // Disable low latency for stability
|
|
||||||
startLevel: -1, // Auto select quality
|
|
||||||
// Faster loading
|
|
||||||
enableWorker: true,
|
|
||||||
startFragPrefetch: true, // Prefetch next fragment
|
|
||||||
testBandwidth: true
|
|
||||||
});
|
|
||||||
hls.loadSource(url);
|
|
||||||
hls.attachMedia(video);
|
|
||||||
art.hls = hls;
|
|
||||||
art.on('destroy', () => hls.destroy());
|
|
||||||
|
|
||||||
// Handle HLS errors
|
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
||||||
if (data.fatal) {
|
|
||||||
switch (data.type) {
|
|
||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
||||||
console.warn('HLS network error, trying to recover...');
|
|
||||||
hls.startLoad();
|
|
||||||
break;
|
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
||||||
console.warn('HLS media error, trying to recover...');
|
|
||||||
hls.recoverMediaError();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.error('Fatal HLS error');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
||||||
// Native HLS support (Safari)
|
|
||||||
video.src = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
settings: [
|
|
||||||
{
|
|
||||||
html: 'Speed',
|
|
||||||
selector: [
|
|
||||||
{ html: '0.5x', value: 0.5 },
|
|
||||||
{ html: '0.75x', value: 0.75 },
|
|
||||||
{ html: 'Normal', value: 1, default: true },
|
|
||||||
{ html: '1.25x', value: 1.25 },
|
|
||||||
{ html: '1.5x', value: 1.5 },
|
|
||||||
{ html: '2x', value: 2 }
|
|
||||||
],
|
|
||||||
onSelect(item) {
|
|
||||||
if (currentPlayer) {
|
|
||||||
currentPlayer.playbackRate = item.value;
|
|
||||||
}
|
|
||||||
return item.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
icons: {
|
|
||||||
loading: `<div class="loading__spinner"></div>`,
|
|
||||||
state: `<svg viewBox="0 0 24 24" fill="currentColor" width="64" height="64"><path d="M8 5v14l11-7z"/></svg>`
|
|
||||||
},
|
|
||||||
cssVar: {
|
|
||||||
'--art-theme': '#f5c518',
|
|
||||||
'--art-background-color': '#0f0f0f',
|
|
||||||
'--art-progress-color': '#f5c518',
|
|
||||||
'--art-control-background-color': 'rgba(0, 0, 0, 0.8)',
|
|
||||||
'--art-control-height': '48px',
|
|
||||||
'--art-bottom-gap': '12px'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only add quality if available (ArtPlayer requires array, not undefined)
|
|
||||||
if (qualities.length > 0) {
|
|
||||||
playerConfig.quality = qualities.map((q, i) => ({
|
|
||||||
default: i === 0,
|
|
||||||
html: q,
|
|
||||||
url: url
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize ArtPlayer
|
|
||||||
currentPlayer = new Artplayer(playerConfig);
|
|
||||||
|
|
||||||
// Event handling
|
|
||||||
currentPlayer.on('ready', () => {
|
|
||||||
console.log('Player ready');
|
|
||||||
if (currentPlayer.video) {
|
|
||||||
currentPlayer.video.preload = 'auto';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
currentPlayer.on('video:waiting', () => {
|
|
||||||
console.log('Buffering...');
|
|
||||||
});
|
|
||||||
|
|
||||||
currentPlayer.on('video:canplay', () => {
|
|
||||||
console.log('Can play');
|
|
||||||
});
|
|
||||||
|
|
||||||
currentPlayer.on('error', (error) => {
|
|
||||||
console.error('Player error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return currentPlayer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy current player instance
|
|
||||||
*/
|
|
||||||
export function destroyPlayer() {
|
|
||||||
if (currentPlayer) {
|
|
||||||
currentPlayer.destroy();
|
|
||||||
currentPlayer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current player instance
|
|
||||||
* @returns {Artplayer|null} Current player or null
|
|
||||||
*/
|
|
||||||
export function getPlayer() {
|
|
||||||
return currentPlayer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a lazy-load placeholder with play button
|
|
||||||
* @param {Object} options - Placeholder options
|
|
||||||
* @returns {HTMLElement} Placeholder element
|
|
||||||
*/
|
|
||||||
export function createPlayerPlaceholder(options = {}) {
|
|
||||||
const { poster, onClick } = options;
|
|
||||||
|
|
||||||
const placeholder = document.createElement('div');
|
|
||||||
placeholder.className = 'player-skeleton';
|
|
||||||
|
|
||||||
if (poster) {
|
|
||||||
placeholder.style.backgroundImage = `url(${poster})`;
|
|
||||||
placeholder.style.backgroundSize = 'cover';
|
|
||||||
placeholder.style.backgroundPosition = 'center';
|
|
||||||
}
|
|
||||||
|
|
||||||
placeholder.innerHTML = `
|
|
||||||
<div class="player-skeleton__play">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M8 5v14l11-7z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (onClick) {
|
|
||||||
placeholder.addEventListener('click', onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
return placeholder;
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { Haptics, ImpactStyle } from '../js/capacitor-mock.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
|
|
||||||
import { api } from './api.js';
|
|
||||||
|
|
||||||
// DOM Elements
|
|
||||||
const elements = {
|
|
||||||
poster: document.getElementById('poster'),
|
|
||||||
backdrop: document.getElementById('backdrop'),
|
|
||||||
title: document.getElementById('title'),
|
|
||||||
originalTitle: document.getElementById('originalTitle'),
|
|
||||||
rating: document.getElementById('rating'),
|
|
||||||
status: document.getElementById('status'),
|
|
||||||
year: document.getElementById('year'),
|
|
||||||
episodes: document.getElementById('episodes'),
|
|
||||||
country: document.getElementById('country'),
|
|
||||||
genre: document.getElementById('genre'),
|
|
||||||
director: document.getElementById('director'),
|
|
||||||
cast: document.getElementById('cast'),
|
|
||||||
description: document.getElementById('description'),
|
|
||||||
btnWatch: document.getElementById('btnWatch'),
|
|
||||||
tags: document.getElementById('tags'),
|
|
||||||
recommendations: document.getElementById('recommendations')
|
|
||||||
};
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const id = params.get('id');
|
|
||||||
const slug = params.get('slug');
|
|
||||||
|
|
||||||
if (!id && !slug) {
|
|
||||||
window.location.href = '/';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const movieSlug = slug || id;
|
|
||||||
const data = await api.getRophimMovie(movieSlug);
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
renderInfo(data.movie || data, data.episodes || []);
|
|
||||||
loadRecommendations();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading info:', e);
|
|
||||||
// Fallback or error state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInfo(movie, episodes) {
|
|
||||||
document.title = `${movie.name || movie.title} - KV-Stream`;
|
|
||||||
|
|
||||||
// Images
|
|
||||||
const posterUrl = movie.poster_url || movie.thumb_url || movie.thumbnail || 'https://via.placeholder.com/300x450?text=No+Poster';
|
|
||||||
const backdropUrl = movie.backdrop_url || posterUrl;
|
|
||||||
|
|
||||||
if (elements.poster) {
|
|
||||||
elements.poster.src = posterUrl;
|
|
||||||
elements.poster.onerror = () => { elements.poster.src = 'https://via.placeholder.com/300x450?text=No+Poster'; };
|
|
||||||
}
|
|
||||||
if (elements.backdrop) elements.backdrop.style.backgroundImage = `url('${backdropUrl}')`;
|
|
||||||
|
|
||||||
// Titles
|
|
||||||
if (elements.title) elements.title.textContent = movie.name || movie.title;
|
|
||||||
if (elements.originalTitle) elements.originalTitle.textContent = movie.origin_name || movie.original_title || '';
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
if (elements.status) {
|
|
||||||
// Infer status
|
|
||||||
let status = 'Đang chiếu'; // Default
|
|
||||||
if (movie.status === 'completed' || (episodes.length > 0 && movie.episode_current === 'Full')) status = 'Hoàn tất';
|
|
||||||
elements.status.innerHTML = `<span style="background:#2ecc71; padding:2px 8px; border-radius:4px; font-size:0.9em; color:#000; font-weight:bold;">${status}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.year) elements.year.textContent = movie.year || 'N/A';
|
|
||||||
|
|
||||||
// Episodes Count
|
|
||||||
if (elements.episodes) {
|
|
||||||
const epCount = episodes[0]?.server_data?.length || 1;
|
|
||||||
const currentEp = movie.episode_current || epCount;
|
|
||||||
const totalEp = movie.episode_total || '?';
|
|
||||||
elements.episodes.textContent = `${epCount}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Country
|
|
||||||
if (elements.country) {
|
|
||||||
const countries = Array.isArray(movie.country) ? movie.country.map(c => c.name) : [movie.country];
|
|
||||||
elements.country.textContent = countries.filter(Boolean).join(', ') || 'Đang cập nhật';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Genre
|
|
||||||
if (elements.genre) {
|
|
||||||
const genres = Array.isArray(movie.category) ? movie.category.map(c => c.name) : (movie.genre ? movie.genre.split(',') : []);
|
|
||||||
elements.genre.textContent = genres.map(g => g.trim()).join(', ') || 'Đang cập nhật';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Director
|
|
||||||
if (elements.director) {
|
|
||||||
const director = Array.isArray(movie.director) ? movie.director.join(', ') : movie.director;
|
|
||||||
elements.director.textContent = director || 'Đang cập nhật';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cast
|
|
||||||
if (elements.cast) {
|
|
||||||
const cast = Array.isArray(movie.actor) ? movie.actor.join(', ') : (movie.cast ? (Array.isArray(movie.cast) ? movie.cast.join(', ') : movie.cast) : '');
|
|
||||||
elements.cast.textContent = cast || 'Đang cập nhật';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description
|
|
||||||
if (elements.description) {
|
|
||||||
elements.description.innerHTML = movie.content || movie.description || 'Chưa có mô tả.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch Link
|
|
||||||
if (elements.btnWatch) {
|
|
||||||
elements.btnWatch.href = `/watch.html?id=${movie.slug}&slug=${movie.slug}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags (Keywords)
|
|
||||||
if (elements.tags) {
|
|
||||||
// Just use Title and English title as tags for now
|
|
||||||
const tags = [movie.name, movie.origin_name].filter(Boolean);
|
|
||||||
elements.tags.innerHTML = tags.map(t =>
|
|
||||||
`<a href="#" class="action-btn action-btn--glass" style="font-size:0.8rem; padding:4px 12px;">${t}</a>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRecommendations() {
|
|
||||||
if (!elements.recommendations) return;
|
|
||||||
try {
|
|
||||||
const res = await api.getRophimCatalog({ page: 1, limit: 24 });
|
|
||||||
const recs = res.movies || [];
|
|
||||||
|
|
||||||
elements.recommendations.innerHTML = recs.map(v => `
|
|
||||||
<a href="/info.html?id=${v.slug}&slug=${v.slug}" class="rec-card">
|
|
||||||
<img src="${v.thumbnail}" class="rec-img" loading="lazy">
|
|
||||||
<div style="font-weight:600; font-size:0.9rem; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${v.title}</div>
|
|
||||||
<div style="font-size:0.8rem; color:#aaa;">${v.year || ''}</div>
|
|
||||||
</a>
|
|
||||||
`).join('');
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to load recs', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
/**
|
|
||||||
* TV-Style Keyboard Navigation
|
|
||||||
* Handles Arrow keys to navigate horizontally through sliders and vertically between rows.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class KeyboardNavigation {
|
|
||||||
constructor() {
|
|
||||||
this.currentFocus = null;
|
|
||||||
this.isEnabled = false;
|
|
||||||
|
|
||||||
// Selectors for focusable items
|
|
||||||
this.selectors = [
|
|
||||||
'.video-card',
|
|
||||||
'.hero__btn',
|
|
||||||
'.slider-btn',
|
|
||||||
'#topSearchBtn',
|
|
||||||
'.nav-item',
|
|
||||||
'.category-card',
|
|
||||||
'.tab-btn',
|
|
||||||
'.episode-row',
|
|
||||||
'.recommendation-card'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.isEnabled = true;
|
|
||||||
document.addEventListener('keydown', this.handleKey.bind(this));
|
|
||||||
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
|
||||||
|
|
||||||
// Initial focus?
|
|
||||||
// Usually wait for user to press a key to enter "Keyboard Mode"
|
|
||||||
// so we don't show focus rings to mouse users.
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseMove() {
|
|
||||||
// If mouse moves, likely user is using mouse.
|
|
||||||
// Optional: clear focus to avoid conflict?
|
|
||||||
// For now, let's keep them separate or just let hover take precedence.
|
|
||||||
if (this.currentFocus) {
|
|
||||||
this.currentFocus.blur();
|
|
||||||
this.currentFocus.classList.remove('keyboard-focused');
|
|
||||||
this.currentFocus = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKey(e) {
|
|
||||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
|
||||||
e.preventDefault(); // Prevent default page scroll
|
|
||||||
|
|
||||||
if (!this.currentFocus) {
|
|
||||||
this.focusFirstVisible();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextTarget = null;
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowRight':
|
|
||||||
nextTarget = this.moveHorizontal(1);
|
|
||||||
break;
|
|
||||||
case 'ArrowLeft':
|
|
||||||
nextTarget = this.moveHorizontal(-1);
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
nextTarget = this.moveVertical(-1);
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
nextTarget = this.moveVertical(1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextTarget) {
|
|
||||||
this.setFocus(nextTarget);
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
if (this.currentFocus) {
|
|
||||||
this.currentFocus.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
focusFirstVisible() {
|
|
||||||
// Find first video card in viewport
|
|
||||||
const candidates = document.querySelectorAll('.video-card');
|
|
||||||
if (candidates.length > 0) {
|
|
||||||
this.setFocus(candidates[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFocus(el) {
|
|
||||||
if (this.currentFocus) {
|
|
||||||
this.currentFocus.classList.remove('keyboard-focused');
|
|
||||||
// Trigger mouseleave logic if needed to reset z-index?
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentFocus = el;
|
|
||||||
el.classList.add('keyboard-focused');
|
|
||||||
el.focus({ preventScroll: true }); // Native focus
|
|
||||||
|
|
||||||
// Smooth scroll into view
|
|
||||||
el.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'center',
|
|
||||||
inline: 'center'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
moveHorizontal(direction) {
|
|
||||||
// 1. Try siblings first (if in a list)
|
|
||||||
// If direction is 1 (Right), look for nextElementSibling
|
|
||||||
if (!this.currentFocus) return null;
|
|
||||||
|
|
||||||
const allFocusable = Array.from(document.querySelectorAll(this.selectors.join(',')));
|
|
||||||
const currentIndex = allFocusable.indexOf(this.currentFocus);
|
|
||||||
|
|
||||||
if (currentIndex === -1) return null;
|
|
||||||
|
|
||||||
const nextIndex = currentIndex + direction;
|
|
||||||
if (nextIndex >= 0 && nextIndex < allFocusable.length) {
|
|
||||||
// Simple DOM order check
|
|
||||||
// BUT for sliders, DOM order matches visual order usually.
|
|
||||||
// Check if they are in the same container?
|
|
||||||
// If dragging across rows, Horizontal arrow shouldn't jump rows if possible?
|
|
||||||
// But flattening functionality is easier: just go to next DOM element.
|
|
||||||
|
|
||||||
// Refinement: If next element is in a DIFFERENT slider row, only jump if it's logically close?
|
|
||||||
// Ideally Right Arrow should stay in row.
|
|
||||||
|
|
||||||
const currentRect = this.currentFocus.getBoundingClientRect();
|
|
||||||
const nextEl = allFocusable[nextIndex];
|
|
||||||
const nextRect = nextEl.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Heuristic: If vertical distance is large, it's a new row.
|
|
||||||
// If delta Y > height/2, maybe block horizontal nav?
|
|
||||||
const verticalDist = Math.abs(currentRect.top - nextRect.top);
|
|
||||||
if (verticalDist > currentRect.height * 0.5) {
|
|
||||||
// New row. Should arrow keys wrap?
|
|
||||||
// User said "scrollable to the right". Usually means stay in row or wrap.
|
|
||||||
// Let's allow wrapping for now, or strict row logic?
|
|
||||||
// Strict Row Logic is better for TV.
|
|
||||||
// If I am at end of row, right arrow does nothing or goes to "Next" button?
|
|
||||||
|
|
||||||
// Let's rely on simple DOM order for now as "good enough" for v1
|
|
||||||
// except if the user specifically requested "scrollable right".
|
|
||||||
// If I press Right at end of row, and it jumps to next row, that's okay.
|
|
||||||
}
|
|
||||||
return nextEl;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
moveVertical(direction) {
|
|
||||||
// Find closest element in the visual direction
|
|
||||||
if (!this.currentFocus) return null;
|
|
||||||
|
|
||||||
const currentRect = this.currentFocus.getBoundingClientRect();
|
|
||||||
const centerX = currentRect.left + currentRect.width / 2;
|
|
||||||
const allFocusable = Array.from(document.querySelectorAll(this.selectors.join(',')));
|
|
||||||
|
|
||||||
// Filter elements that are strictly Above/Below
|
|
||||||
const candidates = allFocusable.filter(el => {
|
|
||||||
if (el === this.currentFocus) return false;
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (direction === 1) { // Down
|
|
||||||
return rect.top >= currentRect.bottom - (currentRect.height * 0.2); // permit slight overlap
|
|
||||||
} else { // Up
|
|
||||||
return rect.bottom <= currentRect.top + (currentRect.height * 0.2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (candidates.length === 0) return null;
|
|
||||||
|
|
||||||
// Find the one with minimum distance
|
|
||||||
// Distance = Vertical Diff + Horizontal Diff penalty
|
|
||||||
let bestCandidate = null;
|
|
||||||
let minDistance = Infinity;
|
|
||||||
|
|
||||||
candidates.forEach(el => {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const elCenterX = rect.left + rect.width / 2;
|
|
||||||
const elCenterY = rect.top + rect.height / 2;
|
|
||||||
|
|
||||||
// Vertical distance (primary)
|
|
||||||
const vDist = Math.abs(rect.top - currentRect.top);
|
|
||||||
|
|
||||||
// Horizontal alignment penalty
|
|
||||||
const hDist = Math.abs(elCenterX - centerX);
|
|
||||||
|
|
||||||
// Weighted distance: Vertical matter, but horizontally closest is best within that band.
|
|
||||||
// Actually, we usually want the "row immediately below".
|
|
||||||
// So sort by Vertical distance first.
|
|
||||||
|
|
||||||
// Simple Euclidean distance?
|
|
||||||
const dist = Math.sqrt(Math.pow(vDist, 2) + Math.pow(hDist, 2));
|
|
||||||
|
|
||||||
if (dist < minDistance) {
|
|
||||||
minDistance = dist;
|
|
||||||
bestCandidate = el;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return bestCandidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
/**
|
|
||||||
* Search Modal Functionality
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { api } from './api.js';
|
|
||||||
|
|
||||||
// Search state
|
|
||||||
let searchTimeout = null;
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
|
||||||
|
|
||||||
// Elements
|
|
||||||
const searchModal = document.getElementById('searchModal');
|
|
||||||
const searchBackdrop = document.getElementById('searchBackdrop');
|
|
||||||
const searchInput = document.getElementById('searchInput');
|
|
||||||
const closeSearch = document.getElementById('closeSearch');
|
|
||||||
|
|
||||||
const searchLoading = document.getElementById('searchLoading');
|
|
||||||
const searchGrid = document.getElementById('searchGrid');
|
|
||||||
|
|
||||||
// Search button in sidebar
|
|
||||||
const searchNavButton = document.querySelector('[data-view="search"]');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open search modal
|
|
||||||
*/
|
|
||||||
function openSearchModal() {
|
|
||||||
searchModal.classList.add('active');
|
|
||||||
setTimeout(() => searchInput.focus(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close search modal
|
|
||||||
*/
|
|
||||||
function closeSearchModal() {
|
|
||||||
searchModal.classList.remove('active');
|
|
||||||
searchInput.value = '';
|
|
||||||
searchGrid.innerHTML = '';
|
|
||||||
searchLoading.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform search
|
|
||||||
*/
|
|
||||||
async function performSearch(query) {
|
|
||||||
if (!query || query.trim().length < 2) {
|
|
||||||
searchGrid.innerHTML = '';
|
|
||||||
searchLoading.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading
|
|
||||||
searchLoading.style.display = 'flex';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Search in the API
|
|
||||||
const response = await api.searchRophim(query);
|
|
||||||
|
|
||||||
searchLoading.style.display = 'none';
|
|
||||||
|
|
||||||
if (response && response.movies && response.movies.length > 0) {
|
|
||||||
// Display results
|
|
||||||
searchGrid.innerHTML = response.movies.map(movie => {
|
|
||||||
return `
|
|
||||||
<div class="video-card" data-id="${movie.slug}" onclick="window.location.href='/watch.html?id=${movie.slug}&slug=${movie.slug}'">
|
|
||||||
<div class="video-card__container">
|
|
||||||
<div class="video-card__thumbnail">
|
|
||||||
<img src="${movie.thumbnail || 'https://via.placeholder.com/300x450?text=No+Image'}" alt="${movie.title}" loading="lazy">
|
|
||||||
</div>
|
|
||||||
<div class="video-card__overlay">
|
|
||||||
<div class="video-card__info">
|
|
||||||
<h3 class="video-card__title">${movie.title}</h3>
|
|
||||||
<div class="video-card__meta">
|
|
||||||
<span>${movie.year || ''}</span>
|
|
||||||
${movie.quality ? `<span>${movie.quality}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
} else {
|
|
||||||
// No results
|
|
||||||
searchGrid.innerHTML = `
|
|
||||||
<div style="grid-column: 1/-1; text-align: center; padding: 60px 20px; color: var(--apple-text-tertiary);">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48" style="opacity: 0.5; margin-bottom: 16px;">
|
|
||||||
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
|
||||||
</svg>
|
|
||||||
<p>No results found for "${query}"</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search failed:', error);
|
|
||||||
searchLoading.style.display = 'none';
|
|
||||||
searchGrid.innerHTML = `
|
|
||||||
<div style="grid-column: 1/-1; text-align: center; padding: 60px 20px; color: var(--apple-error);">
|
|
||||||
<p>Search failed. Please try again.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup search event listeners
|
|
||||||
*/
|
|
||||||
export function initSearch() {
|
|
||||||
// Collect all possible search triggers
|
|
||||||
const triggers = [
|
|
||||||
document.getElementById('headerSearchBtn'),
|
|
||||||
document.getElementById('mobileSearchBtn'),
|
|
||||||
document.querySelector('[data-view="search"]'),
|
|
||||||
document.querySelector('button[data-view="search"]') // Mobile bottom nav
|
|
||||||
];
|
|
||||||
|
|
||||||
triggers.forEach(btn => {
|
|
||||||
if (btn) {
|
|
||||||
// Remove old listeners by cloning (simple way) or just add new one
|
|
||||||
// Since we are shifting logic, just add listener
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation(); // Stop bubbling
|
|
||||||
openSearchModal();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close button
|
|
||||||
if (closeSearch) {
|
|
||||||
closeSearch.addEventListener('click', closeSearchModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backdrop click
|
|
||||||
if (searchBackdrop) {
|
|
||||||
searchBackdrop.addEventListener('click', closeSearchModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search input with debouncing
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.addEventListener('input', (e) => {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
const query = e.target.value;
|
|
||||||
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
performSearch(query);
|
|
||||||
}, SEARCH_DEBOUNCE_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enter key to search immediately
|
|
||||||
searchInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
performSearch(e.target.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
// Cmd/Ctrl + K to open search
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
openSearchModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape to close
|
|
||||||
if (e.key === 'Escape' && searchModal.classList.contains('active')) {
|
|
||||||
closeSearchModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for ?search= URL parameter and auto-perform search
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const searchQuery = urlParams.get('search');
|
|
||||||
if (searchQuery && searchQuery.trim()) {
|
|
||||||
// Open modal and perform search
|
|
||||||
setTimeout(() => {
|
|
||||||
openSearchModal();
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.value = searchQuery;
|
|
||||||
}
|
|
||||||
performSearch(searchQuery);
|
|
||||||
|
|
||||||
// Clean up the URL without refreshing
|
|
||||||
const newUrl = window.location.pathname;
|
|
||||||
window.history.replaceState({}, '', newUrl);
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on load
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initSearch);
|
|
||||||
} else {
|
|
||||||
initSearch();
|
|
||||||
}
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
/**
|
|
||||||
* Image Cache Service
|
|
||||||
* Caches movie posters and thumbnails for faster loading
|
|
||||||
*/
|
|
||||||
|
|
||||||
const IMAGE_CACHE_NAME = 'kvstream-images-v1';
|
|
||||||
const IMAGE_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
||||||
const IMAGE_CACHE_MAX_ITEMS = 500;
|
|
||||||
|
|
||||||
class ImageCacheService {
|
|
||||||
constructor() {
|
|
||||||
this.memoryCache = new Map();
|
|
||||||
this.cacheEnabled = 'caches' in window;
|
|
||||||
this.pendingRequests = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached image or fetch and cache it
|
|
||||||
* @param {string} url - Image URL
|
|
||||||
* @returns {Promise<string>} - Blob URL for the image
|
|
||||||
*/
|
|
||||||
async getCachedImage(url) {
|
|
||||||
if (!url || !this.cacheEnabled) return url;
|
|
||||||
|
|
||||||
// Check memory cache first (fastest)
|
|
||||||
if (this.memoryCache.has(url)) {
|
|
||||||
return this.memoryCache.get(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicate pending requests
|
|
||||||
if (this.pendingRequests.has(url)) {
|
|
||||||
return this.pendingRequests.get(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPromise = this._fetchAndCache(url);
|
|
||||||
this.pendingRequests.set(url, fetchPromise);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await fetchPromise;
|
|
||||||
return result;
|
|
||||||
} finally {
|
|
||||||
this.pendingRequests.delete(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _fetchAndCache(url) {
|
|
||||||
try {
|
|
||||||
const cache = await caches.open(IMAGE_CACHE_NAME);
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cachedResponse = await cache.match(url);
|
|
||||||
if (cachedResponse) {
|
|
||||||
const blob = await cachedResponse.blob();
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
this.memoryCache.set(url, blobUrl);
|
|
||||||
return blobUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch and cache
|
|
||||||
const response = await fetch(url, { mode: 'cors', credentials: 'omit' });
|
|
||||||
if (response.ok) {
|
|
||||||
const responseClone = response.clone();
|
|
||||||
cache.put(url, responseClone);
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
this.memoryCache.set(url, blobUrl);
|
|
||||||
|
|
||||||
// Cleanup old cache entries periodically
|
|
||||||
this._cleanupCache(cache);
|
|
||||||
|
|
||||||
return blobUrl;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silent fail - return original URL
|
|
||||||
console.warn('Image cache failed:', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preload images for faster display
|
|
||||||
* @param {string[]} urls - Array of image URLs to preload
|
|
||||||
*/
|
|
||||||
async preloadImages(urls) {
|
|
||||||
if (!urls || urls.length === 0) return;
|
|
||||||
|
|
||||||
// Batch preload with limited concurrency
|
|
||||||
const batchSize = 6;
|
|
||||||
for (let i = 0; i < urls.length; i += batchSize) {
|
|
||||||
const batch = urls.slice(i, i + batchSize);
|
|
||||||
await Promise.allSettled(batch.map(url => this.getCachedImage(url)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create optimized image element with lazy loading and caching
|
|
||||||
* @param {string} url - Image source URL
|
|
||||||
* @param {string} alt - Alt text
|
|
||||||
* @param {string} className - CSS class
|
|
||||||
* @returns {HTMLImageElement}
|
|
||||||
*/
|
|
||||||
createCachedImage(url, alt = '', className = '') {
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.alt = alt;
|
|
||||||
img.className = className;
|
|
||||||
img.loading = 'lazy';
|
|
||||||
img.decoding = 'async';
|
|
||||||
|
|
||||||
// Set placeholder first
|
|
||||||
img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 450"%3E%3Crect fill="%23222"%3E%3C/rect%3E%3C/svg%3E';
|
|
||||||
|
|
||||||
// Then load cached image
|
|
||||||
if (url) {
|
|
||||||
this.getCachedImage(url).then(cachedUrl => {
|
|
||||||
img.src = cachedUrl;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return img;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup old cache entries
|
|
||||||
*/
|
|
||||||
async _cleanupCache(cache) {
|
|
||||||
try {
|
|
||||||
const keys = await cache.keys();
|
|
||||||
if (keys.length > IMAGE_CACHE_MAX_ITEMS) {
|
|
||||||
// Remove oldest 20% of entries
|
|
||||||
const toRemove = Math.floor(keys.length * 0.2);
|
|
||||||
for (let i = 0; i < toRemove; i++) {
|
|
||||||
await cache.delete(keys[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all cached images
|
|
||||||
*/
|
|
||||||
async clearCache() {
|
|
||||||
this.memoryCache.clear();
|
|
||||||
if (this.cacheEnabled) {
|
|
||||||
await caches.delete(IMAGE_CACHE_NAME);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache statistics
|
|
||||||
*/
|
|
||||||
async getCacheStats() {
|
|
||||||
const stats = {
|
|
||||||
memoryItems: this.memoryCache.size,
|
|
||||||
cacheItems: 0,
|
|
||||||
cacheSize: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.cacheEnabled) {
|
|
||||||
try {
|
|
||||||
const cache = await caches.open(IMAGE_CACHE_NAME);
|
|
||||||
const keys = await cache.keys();
|
|
||||||
stats.cacheItems = keys.length;
|
|
||||||
} catch (e) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const imageCache = new ImageCacheService();
|
|
||||||
|
|
||||||
// Auto-preload visible images on scroll
|
|
||||||
let preloadObserver = null;
|
|
||||||
|
|
||||||
export function setupImagePreloading() {
|
|
||||||
if (preloadObserver) return;
|
|
||||||
|
|
||||||
preloadObserver = new IntersectionObserver((entries) => {
|
|
||||||
const urls = entries
|
|
||||||
.filter(e => e.isIntersecting)
|
|
||||||
.map(e => e.target.dataset.src || e.target.src)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (urls.length > 0) {
|
|
||||||
imageCache.preloadImages(urls);
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
rootMargin: '200px',
|
|
||||||
threshold: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Observe all images with data-src or src
|
|
||||||
document.querySelectorAll('img[data-src], img[src]').forEach(img => {
|
|
||||||
preloadObserver.observe(img);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default imageCache;
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Base Styles
|
|
||||||
PIXEL-PERFECT NETFLIX BASE STYLES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
RESET & FOUNDATION
|
|
||||||
============================================ */
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-size: 16px;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-family);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: var(--font-weight-regular);
|
|
||||||
line-height: var(--line-height-normal);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
background-color: var(--netflix-bg);
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SCROLLBAR HIDING
|
|
||||||
============================================ */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SELECTION
|
|
||||||
============================================ */
|
|
||||||
::selection {
|
|
||||||
background: var(--netflix-red);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LINKS
|
|
||||||
============================================ */
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
IMAGES
|
|
||||||
============================================ */
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX SHIMMER ANIMATION
|
|
||||||
============================================ */
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.shimmer {
|
|
||||||
background: linear-gradient(90deg,
|
|
||||||
var(--netflix-bg-card) 25%,
|
|
||||||
var(--netflix-bg-elevated) 50%,
|
|
||||||
var(--netflix-bg-card) 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LOADING STATES
|
|
||||||
============================================ */
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
gap: 16px;
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading__spinner {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 3px solid var(--netflix-bg-elevated);
|
|
||||||
border-top-color: var(--netflix-red);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
EMPTY STATES
|
|
||||||
============================================ */
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 80px 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state svg {
|
|
||||||
opacity: 0.3;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state h2 {
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
UTILITY CLASSES
|
|
||||||
============================================ */
|
|
||||||
.text-match {
|
|
||||||
color: var(--netflix-green) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: var(--netflix-text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-red {
|
|
||||||
color: var(--netflix-red) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
FOCUS STYLES (Accessibility)
|
|
||||||
============================================ */
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid var(--netflix-red);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus:not(:focus-visible),
|
|
||||||
a:focus:not(:focus-visible) {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Button Components
|
|
||||||
PIXEL-PERFECT NETFLIX BUTTONS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
BASE BUTTON
|
|
||||||
============================================ */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
height: var(--btn-height);
|
|
||||||
padding: var(--btn-padding);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
border-radius: var(--btn-radius);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX PRIMARY (White)
|
|
||||||
============================================ */
|
|
||||||
.btn--primary {
|
|
||||||
background: var(--netflix-text);
|
|
||||||
color: var(--netflix-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--primary:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX SECONDARY (Gray)
|
|
||||||
============================================ */
|
|
||||||
.btn--secondary {
|
|
||||||
background: rgba(109, 109, 110, 0.7);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--secondary:hover {
|
|
||||||
background: rgba(109, 109, 110, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX RED
|
|
||||||
============================================ */
|
|
||||||
.btn--red {
|
|
||||||
background: var(--netflix-red);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--red:hover {
|
|
||||||
background: var(--netflix-red-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
GHOST (Outline)
|
|
||||||
============================================ */
|
|
||||||
.btn--ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
border: 1px solid var(--netflix-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--ghost:hover {
|
|
||||||
border-color: var(--netflix-text);
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
ICON BUTTON (Circle)
|
|
||||||
============================================ */
|
|
||||||
.btn--icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(42, 42, 42, 0.6);
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--icon:hover {
|
|
||||||
background: rgba(42, 42, 42, 0.9);
|
|
||||||
border-color: var(--netflix-text);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SMALL VARIANT
|
|
||||||
============================================ */
|
|
||||||
.btn--sm {
|
|
||||||
height: var(--btn-height-sm);
|
|
||||||
padding: 0 16px;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--sm svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,502 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Video Card Components
|
|
||||||
PIXEL-PERFECT NETFLIX CARDS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX VIDEO CARD - Base Styles
|
|
||||||
============================================ */
|
|
||||||
.video-card {
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 var(--card-width-desktop);
|
|
||||||
width: var(--card-width-desktop);
|
|
||||||
aspect-ratio: var(--card-aspect-ratio);
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: var(--z-card);
|
|
||||||
transition: z-index 0s var(--transition-card);
|
|
||||||
scroll-snap-align: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card:hover {
|
|
||||||
z-index: var(--z-card-hover);
|
|
||||||
transition: z-index 0s 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
overflow: visible;
|
|
||||||
background: var(--netflix-bg-card);
|
|
||||||
transition: transform var(--transition-card), box-shadow var(--transition-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX HOVER EXPANSION EFFECT
|
|
||||||
============================================ */
|
|
||||||
.video-card:hover .video-card__container {
|
|
||||||
transform: scale(var(--card-hover-scale));
|
|
||||||
box-shadow: var(--shadow-card-hover);
|
|
||||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* First card in row: scale from left edge */
|
|
||||||
.video-card:first-child:hover .video-card__container {
|
|
||||||
transform: scale(var(--card-hover-scale));
|
|
||||||
transform-origin: left center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Last card in row: scale from right edge */
|
|
||||||
.video-card:last-child:hover .video-card__container {
|
|
||||||
transform: scale(var(--card-hover-scale));
|
|
||||||
transform-origin: right center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
CARD THUMBNAIL / POSTER
|
|
||||||
============================================ */
|
|
||||||
.video-card__thumbnail {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
transition: border-radius var(--transition-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card:hover .video-card__thumbnail {
|
|
||||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__poster {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__poster img,
|
|
||||||
.video-card__img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: transform 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card:hover .video-card__poster img,
|
|
||||||
.video-card:hover .video-card__img {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX INFO BAR (Appears on Hover)
|
|
||||||
============================================ */
|
|
||||||
.video-card__info {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--netflix-bg-card);
|
|
||||||
border-radius: 0 0 var(--card-radius) var(--card-radius);
|
|
||||||
box-shadow: var(--shadow-dropdown);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
transition: all var(--transition-card);
|
|
||||||
z-index: 51;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card:hover .video-card__info {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
INFO BAR CONTROLS
|
|
||||||
============================================ */
|
|
||||||
.video-card__controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__controls-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Netflix Circle Buttons */
|
|
||||||
.circle-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
min-width: 32px;
|
|
||||||
min-height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
|
||||||
background: rgba(42, 42, 42, 0.6);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-btn--primary {
|
|
||||||
background: var(--netflix-text);
|
|
||||||
color: var(--netflix-bg);
|
|
||||||
border-color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-btn--primary:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-btn--outline:hover {
|
|
||||||
border-color: var(--netflix-text);
|
|
||||||
background: rgba(42, 42, 42, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-btn svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* More Info (Expand) Button */
|
|
||||||
.circle-btn--expand {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
METADATA ROW
|
|
||||||
============================================ */
|
|
||||||
.video-card__metadata {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__metadata .match {
|
|
||||||
color: var(--netflix-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__metadata .age,
|
|
||||||
.video-card__metadata .hd {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
||||||
padding: 0 4px;
|
|
||||||
font-size: 9px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__metadata .hd {
|
|
||||||
border-color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
GENRES / TAGS
|
|
||||||
============================================ */
|
|
||||||
.video-card__genres {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__genres span::after {
|
|
||||||
content: '•';
|
|
||||||
margin-left: 4px;
|
|
||||||
color: var(--netflix-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__genres span:last-child::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
VIDEO PREVIEW (Optional)
|
|
||||||
============================================ */
|
|
||||||
.video-card__video-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: #000;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card:hover .video-card__video-wrapper {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__preview-video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
PLAY BUTTON OVERLAY
|
|
||||||
============================================ */
|
|
||||||
.video-card__overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--transition-base);
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card:hover .video-card__overlay {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__play-btn {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #000;
|
|
||||||
transition: transform var(--transition-fast);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__play-btn:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__play-btn svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin-left: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
PROGRESS BAR (Watch History)
|
|
||||||
============================================ */
|
|
||||||
.video-card__progress {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
z-index: 15;
|
|
||||||
border-radius: 0 0 var(--card-radius) var(--card-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--netflix-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
VIDEO TAGS (Top Left Badges)
|
|
||||||
============================================ */
|
|
||||||
.video-tags {
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
left: 6px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-tag {
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-tag--new {
|
|
||||||
background: var(--netflix-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-tag--series {
|
|
||||||
background: #00a8e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-tag--trailer {
|
|
||||||
background: #ff9500;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
QUALITY BADGE
|
|
||||||
============================================ */
|
|
||||||
.poster-badge {
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
EPISODE BADGE
|
|
||||||
============================================ */
|
|
||||||
.episode-badge {
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
color: var(--netflix-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
YEAR BADGE
|
|
||||||
============================================ */
|
|
||||||
.year-badge {
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
RATING BADGES
|
|
||||||
============================================ */
|
|
||||||
.tomato-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tomato-badge--fresh {
|
|
||||||
background: #e50914;
|
|
||||||
/* Netflix Red */
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tomato-badge--rotten {
|
|
||||||
background: #333;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.numeric-rating {
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
color: #000;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: var(--font-weight-black);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
META CONTAINERS (Positional Clusters)
|
|
||||||
============================================ */
|
|
||||||
.card-meta-bottom-right {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 6px;
|
|
||||||
right: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-meta-bottom-left {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 6px;
|
|
||||||
left: 6px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
CARD TITLE & META
|
|
||||||
============================================ */
|
|
||||||
.video-card__title {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__meta {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__duration {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 6px;
|
|
||||||
right: 6px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__resolution {
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
left: 6px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: var(--netflix-red);
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keyboard Navigation */
|
|
||||||
.video-card.keyboard-focused {
|
|
||||||
z-index: var(--z-card-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card.keyboard-focused .video-card__container {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 0 0 3px var(--netflix-red), var(--shadow-card-hover);
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Form Components
|
|
||||||
Search, Categories, Inputs
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Search */
|
|
||||||
.search {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 600px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__input {
|
|
||||||
width: 100%;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0 var(--spacing-lg);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__input::placeholder {
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
box-shadow: var(--shadow-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__results {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 8px);
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: none;
|
|
||||||
z-index: var(--z-elevated);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__results.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__result {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__result:hover {
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__result-thumb {
|
|
||||||
width: 80px;
|
|
||||||
height: 45px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
object-fit: cover;
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__result-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__result-title {
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__result-meta {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Categories */
|
|
||||||
.categories {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: var(--spacing-sm);
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.categories::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category:hover {
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-color: var(--color-border-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category--active {
|
|
||||||
background: var(--color-accent);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
box-shadow: 0 0 15px var(--color-accent-glow);
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Loading States
|
|
||||||
Spinners, Skeletons, Empty States
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
padding: var(--spacing-3xl);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading__spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 3px solid var(--color-border);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty State */
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
padding: var(--spacing-3xl);
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state svg {
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state h2 {
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
@ -1,413 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Modal Components
|
|
||||||
PIXEL-PERFECT NETFLIX MODALS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
PLAYER MODAL
|
|
||||||
============================================ */
|
|
||||||
.player-modal {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal.active {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.9);
|
|
||||||
animation: fadeIn 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__content {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1100px;
|
|
||||||
max-height: 90vh;
|
|
||||||
margin: 40px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
background: var(--netflix-bg-card);
|
|
||||||
border-radius: 6px;
|
|
||||||
animation: slideUp 0.3s ease;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__content::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__close {
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
right: 16px;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
background: var(--netflix-bg);
|
|
||||||
border: none;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
z-index: 10;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__close:hover {
|
|
||||||
background: var(--netflix-text);
|
|
||||||
color: var(--netflix-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__close svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MODAL INFO SECTION
|
|
||||||
============================================ */
|
|
||||||
.player-modal__info {
|
|
||||||
padding: 20px 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
background: linear-gradient(to top, var(--netflix-bg-card), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__title {
|
|
||||||
font-size: var(--font-size-2xl);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__meta {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__meta span::after {
|
|
||||||
content: '•';
|
|
||||||
margin-left: 8px;
|
|
||||||
color: var(--netflix-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__meta span:last-child::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
QUALITY SELECTOR
|
|
||||||
============================================ */
|
|
||||||
.player-modal__quality {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-btn {
|
|
||||||
padding: 6px 14px;
|
|
||||||
background: rgba(42, 42, 42, 0.8);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: var(--btn-radius);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-btn:hover {
|
|
||||||
background: rgba(60, 60, 60, 0.9);
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-btn.active {
|
|
||||||
background: var(--netflix-red);
|
|
||||||
border-color: var(--netflix-red);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
PLAYER CONTAINER
|
|
||||||
============================================ */
|
|
||||||
.player-container {
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
background: #000;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
EPISODE LIST
|
|
||||||
============================================ */
|
|
||||||
.player-modal__episodes {
|
|
||||||
margin-top: 0;
|
|
||||||
padding: 20px 24px;
|
|
||||||
background: var(--netflix-bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__episodes-title {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__episodes-title::before {
|
|
||||||
content: '';
|
|
||||||
width: 3px;
|
|
||||||
height: 16px;
|
|
||||||
background: var(--netflix-red);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episode-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(50px, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episode-btn {
|
|
||||||
padding: 10px 8px;
|
|
||||||
background: var(--netflix-bg-elevated);
|
|
||||||
border: 1px solid var(--netflix-border);
|
|
||||||
border-radius: var(--btn-radius);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episode-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-color: var(--netflix-red);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.episode-btn.active {
|
|
||||||
background: var(--netflix-red);
|
|
||||||
border-color: var(--netflix-red);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
GENERIC MODAL
|
|
||||||
============================================ */
|
|
||||||
.modal {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.active {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal__backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal__content {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 450px;
|
|
||||||
margin: 24px;
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--netflix-bg-card);
|
|
||||||
border-radius: 6px;
|
|
||||||
animation: slideUp 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal__title {
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal__actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
FORM ELEMENTS
|
|
||||||
============================================ */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--netflix-bg-elevated);
|
|
||||||
border: 1px solid var(--netflix-border);
|
|
||||||
border-radius: var(--btn-radius);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input::placeholder {
|
|
||||||
color: var(--netflix-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--netflix-text);
|
|
||||||
background: var(--netflix-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
select.input {
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%238c8c8c' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 14px center;
|
|
||||||
padding-right: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
TOAST NOTIFICATIONS
|
|
||||||
============================================ */
|
|
||||||
.toast-container {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 80px;
|
|
||||||
right: 24px;
|
|
||||||
z-index: calc(var(--z-modal) + 100);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
padding: 14px 20px;
|
|
||||||
background: var(--netflix-bg-card);
|
|
||||||
border-radius: var(--btn-radius);
|
|
||||||
box-shadow: var(--shadow-dropdown);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
animation: slideIn 0.3s ease;
|
|
||||||
min-width: 260px;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast--success {
|
|
||||||
border-left: 3px solid var(--netflix-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast--error {
|
|
||||||
border-left: 3px solid var(--netflix-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast--info {
|
|
||||||
border-left: 3px solid #00a8e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
PLAYER SKELETON
|
|
||||||
============================================ */
|
|
||||||
.player-skeleton {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--netflix-bg-elevated);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-skeleton__play {
|
|
||||||
width: 70px;
|
|
||||||
height: 70px;
|
|
||||||
background: var(--netflix-text);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-skeleton__play:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-skeleton__play svg {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
color: var(--netflix-bg);
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
/* Base Video Grid Definition (Desktop) */
|
|
||||||
.video-grid {
|
|
||||||
display: grid !important;
|
|
||||||
/* Larger cards for better visibility */
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)) !important;
|
|
||||||
gap: var(--spacing-lg) !important;
|
|
||||||
padding: var(--spacing-lg) 4%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure cards inside grid take full width */
|
|
||||||
.video-grid .video-card {
|
|
||||||
width: 100%;
|
|
||||||
/* Override fixed width if any */
|
|
||||||
flex: none;
|
|
||||||
/* Override flex */
|
|
||||||
aspect-ratio: 2/3;
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Medium screens - slightly smaller cards */
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.video-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablet */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.video-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
padding: var(--spacing-md) 3%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile - 2 columns */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.video-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
padding: var(--spacing-sm) 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Main Stylesheet
|
|
||||||
Modular CSS Architecture
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file imports all CSS modules.
|
|
||||||
* The styles are split into logical modules for easier maintenance:
|
|
||||||
*
|
|
||||||
* - variables.css: Design tokens (colors, spacing, typography)
|
|
||||||
* - base.css: Reset, global styles
|
|
||||||
* - layout.css: Sidebar, header, app structure
|
|
||||||
* - components/: Reusable UI components
|
|
||||||
* - sections/: Page-specific sections
|
|
||||||
* - responsive.css: All media queries
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* === Core === */
|
|
||||||
@import 'variables.css';
|
|
||||||
@import 'base.css';
|
|
||||||
@import 'layout.css';
|
|
||||||
|
|
||||||
/* === Components === */
|
|
||||||
@import 'components/buttons.css';
|
|
||||||
@import 'components/cards.css';
|
|
||||||
@import 'components/forms.css';
|
|
||||||
@import 'components/loading.css';
|
|
||||||
@import 'components/modals.css';
|
|
||||||
|
|
||||||
/* === Sections === */
|
|
||||||
@import 'sections/hero.css';
|
|
||||||
@import 'sections/sliders.css';
|
|
||||||
@import 'sections/feed.css';
|
|
||||||
|
|
||||||
/* === Responsive (must be last to override) === */
|
|
||||||
@import 'responsive.css';
|
|
||||||
|
|
||||||
/* === Patches (for quick fixes) === */
|
|
||||||
@import 'grid-patch.css';
|
|
||||||
@import 'responsive-patch.css';
|
|
||||||
@import 'search-modal.css';
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Layout Styles
|
|
||||||
PIXEL-PERFECT NETFLIX LAYOUT
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX TOP HEADER
|
|
||||||
============================================ */
|
|
||||||
.netflix-header {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: var(--header-height);
|
|
||||||
background: var(--netflix-bg-header);
|
|
||||||
z-index: var(--z-header);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 var(--row-padding);
|
|
||||||
transition: background 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header.scrolled {
|
|
||||||
background: var(--netflix-bg-header-scrolled);
|
|
||||||
box-shadow: var(--shadow-header);
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-right: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__logo svg,
|
|
||||||
.netflix-header__logo img {
|
|
||||||
height: 28px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__nav-link {
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: var(--font-weight-regular);
|
|
||||||
transition: color var(--transition-fast);
|
|
||||||
white-space: nowrap;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__nav-link:hover {
|
|
||||||
color: var(--netflix-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__nav-link.active {
|
|
||||||
color: var(--netflix-text);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__search {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__search svg {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__profile {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color var(--transition-fast);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__profile:hover {
|
|
||||||
border-color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__profile img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LEGACY SIDEBAR (Hidden on Desktop with Header)
|
|
||||||
============================================ */
|
|
||||||
.sidebar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MAIN CONTENT AREA
|
|
||||||
============================================ */
|
|
||||||
.app-layout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--netflix-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
padding-top: var(--header-height);
|
|
||||||
background: var(--netflix-bg);
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
padding: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX ROW SECTIONS
|
|
||||||
============================================ */
|
|
||||||
.netflix-row-section {
|
|
||||||
position: relative;
|
|
||||||
margin: var(--row-margin) 0;
|
|
||||||
z-index: var(--z-row);
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-row-section:hover {
|
|
||||||
z-index: calc(var(--z-row) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-row-title {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
margin: 0 0 12px var(--row-padding);
|
|
||||||
transition: color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-row-section:hover .netflix-row-title {
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
VIEW TABS
|
|
||||||
============================================ */
|
|
||||||
.view-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding: 0 var(--row-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--netflix-text-muted);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: var(--btn-radius);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab:hover {
|
|
||||||
border-color: var(--netflix-text);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab.active {
|
|
||||||
background: var(--netflix-text);
|
|
||||||
color: var(--netflix-bg);
|
|
||||||
border-color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
FLOATING SEARCH BUTTON
|
|
||||||
============================================ */
|
|
||||||
.floating-search-btn {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
background: var(--netflix-red);
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: calc(var(--z-header) + 1);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-search-btn:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
background: var(--netflix-red-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-search-btn svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
BACK TO TOP BUTTON
|
|
||||||
============================================ */
|
|
||||||
.back-to-top {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 80px;
|
|
||||||
right: 20px;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
background: var(--netflix-bg-card);
|
|
||||||
border: 1px solid var(--netflix-border);
|
|
||||||
border-radius: 50%;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: translateY(20px);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
z-index: 99;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top.visible {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top:hover {
|
|
||||||
background: var(--netflix-red);
|
|
||||||
border-color: var(--netflix-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top svg {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
RESPONSIVE GRID OVERRIDES (Final Layout)
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Mobile (Portrait/Small) - Force 2 Columns for best readability */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.video-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr) !important;
|
|
||||||
gap: 12px !important;
|
|
||||||
padding: 16px 12px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-grid .video-card {
|
|
||||||
aspect-ratio: 2/3 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablet / Landscape Mobile - Balance density */
|
|
||||||
@media (min-width: 601px) and (max-width: 1024px) {
|
|
||||||
.video-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)) !important;
|
|
||||||
gap: 16px !important;
|
|
||||||
padding: 20px 16px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Desktop - Premium Large Cards (Apple TV+ Style) */
|
|
||||||
@media (min-width: 1025px) {
|
|
||||||
.video-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)) !important;
|
|
||||||
gap: 24px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,513 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Responsive Styles
|
|
||||||
PIXEL-PERFECT NETFLIX RESPONSIVENESS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
DESKTOP LARGE (1400px+)
|
|
||||||
============================================ */
|
|
||||||
@media (min-width: 1400px) {
|
|
||||||
:root {
|
|
||||||
--card-width-desktop: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card {
|
|
||||||
flex: 0 0 var(--card-width-desktop);
|
|
||||||
width: var(--card-width-desktop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
DESKTOP (1200px - 1400px)
|
|
||||||
============================================ */
|
|
||||||
@media (min-width: 1200px) and (max-width: 1399px) {
|
|
||||||
:root {
|
|
||||||
--card-width-desktop: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LAPTOP (1024px - 1199px)
|
|
||||||
============================================ */
|
|
||||||
@media (min-width: 1024px) and (max-width: 1199px) {
|
|
||||||
:root {
|
|
||||||
--card-width-desktop: 180px;
|
|
||||||
--card-hover-scale: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__content {
|
|
||||||
max-width: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
TABLET (768px - 1023px)
|
|
||||||
============================================ */
|
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
|
||||||
:root {
|
|
||||||
--card-width-desktop: 160px;
|
|
||||||
--header-height: 56px;
|
|
||||||
--card-hover-scale: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
height: 70vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__content {
|
|
||||||
max-width: 60%;
|
|
||||||
bottom: 25%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__title {
|
|
||||||
font-size: clamp(1.8rem, 4vw, 2.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__description {
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__nav {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__poster-float {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-btn {
|
|
||||||
width: 45px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE (max-width: 767px)
|
|
||||||
============================================ */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
:root {
|
|
||||||
--card-width-desktop: 110px;
|
|
||||||
--card-gap: 6px;
|
|
||||||
--row-padding: 3%;
|
|
||||||
--row-margin: 20px;
|
|
||||||
--header-height: 48px;
|
|
||||||
--card-hover-scale: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE LAYOUT
|
|
||||||
============================================ */
|
|
||||||
.app-layout {
|
|
||||||
padding-bottom: var(--mobile-nav-height);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
padding-top: 0;
|
|
||||||
margin-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX MOBILE HEADER
|
|
||||||
============================================ */
|
|
||||||
.netflix-header {
|
|
||||||
height: var(--header-height);
|
|
||||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header.scrolled {
|
|
||||||
background: rgba(20, 20, 20, 0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__logo svg,
|
|
||||||
.netflix-header__logo img {
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__nav {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netflix-header__right {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide floating search on mobile (use header) */
|
|
||||||
.floating-search-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE SIDEBAR → BOTTOM NAV
|
|
||||||
============================================ */
|
|
||||||
.sidebar {
|
|
||||||
display: flex !important;
|
|
||||||
position: fixed;
|
|
||||||
top: auto;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: var(--mobile-nav-height);
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 8px;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
background: #121212;
|
|
||||||
border-top: 1px solid rgba(51, 51, 51, 0.8);
|
|
||||||
border-right: none;
|
|
||||||
z-index: var(--z-mobile-nav);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__logo {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__nav-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 2px;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 0;
|
|
||||||
color: var(--netflix-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__nav-item svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__nav-item.active {
|
|
||||||
color: var(--netflix-text);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__nav-item.active::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__profile {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE HERO
|
|
||||||
============================================ */
|
|
||||||
.hero {
|
|
||||||
height: 75vh;
|
|
||||||
min-height: 450px;
|
|
||||||
margin-bottom: -60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__gradient-overlay {
|
|
||||||
background:
|
|
||||||
linear-gradient(to top, #141414 0%, rgba(20, 20, 20, 0.6) 30%, transparent 60%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__content {
|
|
||||||
max-width: 100%;
|
|
||||||
bottom: 15%;
|
|
||||||
left: var(--row-padding);
|
|
||||||
right: var(--row-padding);
|
|
||||||
text-align: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__title {
|
|
||||||
font-size: clamp(1.5rem, 6vw, 2rem);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__description {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__metadata {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__actions {
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__btn {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 160px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__btn svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-controls {
|
|
||||||
bottom: 10%;
|
|
||||||
right: 50%;
|
|
||||||
transform: translateX(50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-arrow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__poster-float {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE CARDS - NO HOVER EXPANSION
|
|
||||||
============================================ */
|
|
||||||
.video-card {
|
|
||||||
flex: 0 0 var(--card-width-desktop);
|
|
||||||
width: var(--card-width-desktop);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card:hover .video-card__container,
|
|
||||||
.video-card:focus .video-card__container {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__info {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__overlay {
|
|
||||||
opacity: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__play-btn {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE SLIDERS
|
|
||||||
============================================ */
|
|
||||||
.slider-section {
|
|
||||||
margin: var(--row-margin) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-section__title {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-section__title::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-track {
|
|
||||||
gap: var(--card-gap);
|
|
||||||
padding-bottom: 10px;
|
|
||||||
margin-bottom: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE VIDEO GRID
|
|
||||||
============================================ */
|
|
||||||
.video-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 var(--row-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-grid .video-card {
|
|
||||||
flex: auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE MODALS
|
|
||||||
============================================ */
|
|
||||||
.modal {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal__container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: none;
|
|
||||||
border-radius: 12px 12px 0 0;
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-modal__content {
|
|
||||||
margin: 0;
|
|
||||||
max-height: 100vh;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE MISC
|
|
||||||
============================================ */
|
|
||||||
.section-banner {
|
|
||||||
height: 140px;
|
|
||||||
margin: 16px var(--row-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-banner__title {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-card {
|
|
||||||
min-width: 160px;
|
|
||||||
height: 100px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-card h3 {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tabs {
|
|
||||||
padding: 0 var(--row-padding);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab {
|
|
||||||
padding: 6px 16px;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top {
|
|
||||||
bottom: calc(var(--mobile-nav-height) + 20px);
|
|
||||||
right: 16px;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
EXTRA SMALL MOBILE (max-width: 480px)
|
|
||||||
============================================ */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
:root {
|
|
||||||
--card-width-desktop: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__btn {
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__nav-item {
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__nav-item svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LANDSCAPE MOBILE
|
|
||||||
============================================ */
|
|
||||||
@media (max-width: 767px) and (orientation: landscape) {
|
|
||||||
.hero {
|
|
||||||
height: 90vh;
|
|
||||||
min-height: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__content {
|
|
||||||
bottom: 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__description {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
DESKTOP HOVER INTERACTIONS
|
|
||||||
============================================ */
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
.video-card:hover .video-card__container {
|
|
||||||
transform: scale(var(--card-hover-scale));
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card:hover .video-card__info {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card:hover .video-card__overlay {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
TOUCH DEVICES - NO HOVER
|
|
||||||
============================================ */
|
|
||||||
@media (hover: none) {
|
|
||||||
|
|
||||||
.video-card:hover .video-card__container,
|
|
||||||
.video-card:active .video-card__container {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card__info {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
PRINT STYLES
|
|
||||||
============================================ */
|
|
||||||
@media print {
|
|
||||||
|
|
||||||
.netflix-header,
|
|
||||||
.sidebar,
|
|
||||||
.hero,
|
|
||||||
.floating-search-btn,
|
|
||||||
.back-to-top {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
Search Modal
|
|
||||||
============================================ */
|
|
||||||
.search-modal {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
display: none;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal.active {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.85);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__content {
|
|
||||||
position: relative;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 900px;
|
|
||||||
background: var(--apple-bg-secondary);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
border: 1px solid var(--apple-border);
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
border-bottom: 1px solid var(--apple-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__input {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--apple-bg-tertiary);
|
|
||||||
border: 1px solid var(--apple-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 14px 20px;
|
|
||||||
font-size: 17px;
|
|
||||||
color: var(--apple-text-primary);
|
|
||||||
outline: none;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__input:focus {
|
|
||||||
border-color: var(--apple-accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--apple-accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__input::placeholder {
|
|
||||||
color: var(--apple-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__close {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--apple-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__close:hover {
|
|
||||||
background: var(--apple-bg-elevated);
|
|
||||||
color: var(--apple-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__results {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-empty,
|
|
||||||
.search-loading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
color: var(--apple-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-empty svg,
|
|
||||||
.search-loading svg {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-empty p {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-loading .loading__spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 3px solid var(--apple-bg-elevated);
|
|
||||||
border-top-color: var(--apple-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-grid .video-card {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.search-modal {
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-modal__content {
|
|
||||||
width: 95%;
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,514 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Feed Styles
|
|
||||||
New & Hot, Category Views, Navigation
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* New & Hot Feed (Netflix 2025 Specification) */
|
|
||||||
.new-hot-view {
|
|
||||||
padding: 20px 0 100px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: var(--color-bg-primary);
|
|
||||||
z-index: 100;
|
|
||||||
padding: 10px 0;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0 4%;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-tabs::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-tab {
|
|
||||||
background: #232323;
|
|
||||||
color: #bcbcbc;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-tab.active {
|
|
||||||
background: white;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-feed {
|
|
||||||
padding: 0 4%;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
width: 45px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__month {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #bcbcbc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__day {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__card {
|
|
||||||
background: #181818;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__img-wrapper {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__img-wrapper img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__play {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
border: 1px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__details {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__title {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: white;
|
|
||||||
font-family: 'Outfit', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__btn {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__desc {
|
|
||||||
color: #bcbcbc;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__tag {
|
|
||||||
font-size: 11px;
|
|
||||||
color: white;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-hot-item__tag:not(:last-child)::after {
|
|
||||||
content: '•';
|
|
||||||
margin-left: 8px;
|
|
||||||
color: #e50914;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Side Navigation Menu */
|
|
||||||
.side-menu {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu.active {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu__backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu__content {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 280px;
|
|
||||||
max-width: 80vw;
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu.active .side-menu__content {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu__header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu__title {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu__close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu__item:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge--new {
|
|
||||||
background: var(--color-error);
|
|
||||||
color: white;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search Overlay */
|
|
||||||
.search-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
background: var(--color-bg-primary);
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-overlay.active {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-overlay__container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-overlay__input {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-overlay__input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-overlay__close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer - PhimMoiChill Style */
|
|
||||||
.footer {
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
padding: var(--spacing-3xl) 0 var(--spacing-lg);
|
|
||||||
margin-top: var(--spacing-3xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__container {
|
|
||||||
max-width: var(--container-max);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 var(--spacing-xl);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
gap: var(--spacing-3xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__brand {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__logo-icon {
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__logo-text {
|
|
||||||
font-size: var(--font-size-2xl);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__logo-accent {
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__tagline {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
line-height: var(--line-height-relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__social {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__social-link {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer__social-link:hover {
|
|
||||||
background: var(--color-accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Recommendations Grid */
|
|
||||||
.recommendations-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-card {
|
|
||||||
background: #2f2f2f;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s, transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-card:hover {
|
|
||||||
background: #3a3a3a;
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-card__img-wrapper {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-card img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-card__play {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
background: rgba(30, 30, 30, 0.5);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-card:hover .recommendation-card__play {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-card__content {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-card__meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-card__desc {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #d2d2d2;
|
|
||||||
line-height: 1.5;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 4;
|
|
||||||
line-clamp: 4;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Episodes Section - Netflix 2025 */
|
|
||||||
.modal__episodes {
|
|
||||||
margin-top: 3rem;
|
|
||||||
border-top: 1px solid #404040;
|
|
||||||
padding-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal__section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header - RoPhim Mobile Style */
|
|
||||||
.header__menu-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header__menu-btn:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header__search-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header__search-btn:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header__logo-accent {
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header__tagline {
|
|
||||||
display: block;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-weight: var(--font-weight-regular);
|
|
||||||
}
|
|
||||||
|
|
@ -1,464 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Hero Section
|
|
||||||
PIXEL-PERFECT NETFLIX BILLBOARD
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX HERO BILLBOARD
|
|
||||||
============================================ */
|
|
||||||
.hero-container {
|
|
||||||
margin-bottom: -100px;
|
|
||||||
/* Overlap with rows */
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 80vh;
|
|
||||||
min-height: 500px;
|
|
||||||
max-height: 800px;
|
|
||||||
background: var(--netflix-bg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__video-container {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__backdrop {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__backdrop img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: center 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX VIGNETTE GRADIENTS
|
|
||||||
============================================ */
|
|
||||||
.hero__gradient-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
/* Netflix's signature left-to-right + bottom vignette */
|
|
||||||
background:
|
|
||||||
linear-gradient(to right, rgba(20, 20, 20, 0.9) 0%, rgba(20, 20, 20, 0.5) 30%, transparent 60%),
|
|
||||||
linear-gradient(to top, #141414 0%, rgba(20, 20, 20, 0.7) 15%, transparent 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__vignette {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__vignette--top {
|
|
||||||
top: 0;
|
|
||||||
height: 150px;
|
|
||||||
background: linear-gradient(180deg, rgba(20, 20, 20, 0.5) 0%, transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__vignette--bottom {
|
|
||||||
bottom: 0;
|
|
||||||
height: 50%;
|
|
||||||
background: linear-gradient(to top, #141414 0%, rgba(20, 20, 20, 0.8) 20%, transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
HERO CONTENT
|
|
||||||
============================================ */
|
|
||||||
.hero__content {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 30%;
|
|
||||||
left: var(--row-padding);
|
|
||||||
z-index: 2;
|
|
||||||
max-width: 45%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__info-layer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
animation: fadeSlideUp 0.8s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeSlideUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Netflix Title */
|
|
||||||
.hero__title {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: var(--font-size-hero);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.8);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Metadata (Match %, Year, Rating) */
|
|
||||||
.hero__metadata {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__match {
|
|
||||||
color: var(--netflix-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__age,
|
|
||||||
.hero__quality {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
||||||
padding: 0 4px;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Description */
|
|
||||||
.hero__description {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
text-shadow: 1px 1px 4px rgba(0, 0, 0, 0.7);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX HERO BUTTONS
|
|
||||||
============================================ */
|
|
||||||
.hero__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 28px;
|
|
||||||
border-radius: var(--btn-radius);
|
|
||||||
border: none;
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__btn svg {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Play Button - White */
|
|
||||||
.hero__btn--primary {
|
|
||||||
background: var(--netflix-text);
|
|
||||||
color: var(--netflix-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__btn--primary:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* More Info Button - Gray */
|
|
||||||
.hero__btn--secondary {
|
|
||||||
background: rgba(109, 109, 110, 0.7);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__btn--secondary:hover {
|
|
||||||
background: rgba(109, 109, 110, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
HERO SLIDER CONTROLS
|
|
||||||
============================================ */
|
|
||||||
.hero-slider-track {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-slide {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-controls {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 15%;
|
|
||||||
right: var(--row-padding);
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-indicator {
|
|
||||||
width: 12px;
|
|
||||||
height: 2px;
|
|
||||||
border-radius: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-indicator.active {
|
|
||||||
background: var(--netflix-text);
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-indicator:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-arrow {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 50px;
|
|
||||||
height: 100px;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border: none;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 10;
|
|
||||||
opacity: 0;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero:hover .hero-arrow {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-arrow:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-arrow svg {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-arrow--prev {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-arrow--next {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SECTION BANNERS
|
|
||||||
============================================ */
|
|
||||||
.section-banner {
|
|
||||||
position: relative;
|
|
||||||
height: 180px;
|
|
||||||
margin: 24px var(--row-padding);
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
background: var(--netflix-bg-card);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-banner:hover {
|
|
||||||
transform: scale(1.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-banner__bg {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
transition: transform 0.6s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-banner:hover .section-banner__bg {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-banner__overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-banner__content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
padding: 20px 24px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-banner__title {
|
|
||||||
font-size: var(--font-size-2xl);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
margin: 0;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-banner__subtitle {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
CATEGORY SHORTCUTS
|
|
||||||
============================================ */
|
|
||||||
.category-shortcuts-section {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
overflow-x: auto;
|
|
||||||
display: flex;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-shortcuts-section::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-shortcuts-track {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 0 var(--row-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-card {
|
|
||||||
min-width: 240px;
|
|
||||||
height: 130px;
|
|
||||||
background: linear-gradient(135deg, #2a2a2a, #1a1a1a);
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform var(--transition-base), background var(--transition-base);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
background: linear-gradient(135deg, #333, #222);
|
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-card h3 {
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
margin: 0 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-card span {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--netflix-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
right: 16px;
|
|
||||||
font-size: 20px;
|
|
||||||
color: rgba(255, 255, 255, 0.2);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-card:hover .shortcut-icon {
|
|
||||||
transform: translateX(4px);
|
|
||||||
color: var(--netflix-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SMALL HERO (Category Pages)
|
|
||||||
============================================ */
|
|
||||||
.hero--small {
|
|
||||||
height: 50vh !important;
|
|
||||||
min-height: 350px !important;
|
|
||||||
max-height: 450px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
POSTER FLOAT (Portrait Mode)
|
|
||||||
============================================ */
|
|
||||||
.hero__poster-float {
|
|
||||||
position: absolute;
|
|
||||||
right: 8%;
|
|
||||||
bottom: 15%;
|
|
||||||
height: 65%;
|
|
||||||
aspect-ratio: 2/3;
|
|
||||||
z-index: 5;
|
|
||||||
display: none;
|
|
||||||
animation: posterFloat 1s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes posterFloat {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(40px) scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__poster-float img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero--portrait-mode .hero__poster-float {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero--portrait-mode .hero__content {
|
|
||||||
max-width: 40%;
|
|
||||||
}
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - Content Sliders
|
|
||||||
PIXEL-PERFECT NETFLIX HORIZONTAL ROWS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX ROW CONTAINER
|
|
||||||
============================================ */
|
|
||||||
.slider-section {
|
|
||||||
position: relative;
|
|
||||||
margin: var(--row-margin) 0;
|
|
||||||
z-index: var(--z-row);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-section:hover {
|
|
||||||
z-index: calc(var(--z-row) + 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-section__title {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
margin: 0 0 12px var(--row-padding);
|
|
||||||
transition: color var(--transition-fast);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-section:hover .slider-section__title {
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "Explore All" Link After Title */
|
|
||||||
.slider-section__title::after {
|
|
||||||
content: 'Explore All ›';
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--netflix-red);
|
|
||||||
margin-left: 12px;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-10px);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-section:hover .slider-section__title::after {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX HORIZONTAL SCROLL TRACK
|
|
||||||
============================================ */
|
|
||||||
.slider-track {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--card-gap);
|
|
||||||
padding: 0 var(--row-padding);
|
|
||||||
padding-bottom: 40px;
|
|
||||||
/* Space for hover expansion */
|
|
||||||
margin-bottom: -40px;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: visible;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-track::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX SCROLL BUTTONS
|
|
||||||
============================================ */
|
|
||||||
.slider-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 40px;
|
|
||||||
width: 55px;
|
|
||||||
background: rgba(20, 20, 20, 0.5);
|
|
||||||
border: none;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 30;
|
|
||||||
opacity: 0;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-container:hover .slider-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-btn:hover {
|
|
||||||
background: rgba(20, 20, 20, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-btn svg {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
transition: transform var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-btn:hover svg {
|
|
||||||
transform: scale(1.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-btn--left {
|
|
||||||
left: 0;
|
|
||||||
border-radius: 0 var(--card-radius) var(--card-radius) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-btn--right {
|
|
||||||
right: 0;
|
|
||||||
border-radius: var(--card-radius) 0 0 var(--card-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SECTION HEADER
|
|
||||||
============================================ */
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 0 var(--row-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title::before {
|
|
||||||
content: '';
|
|
||||||
width: 4px;
|
|
||||||
height: 20px;
|
|
||||||
background: var(--netflix-red);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-link {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
transition: color var(--transition-fast);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-link:hover {
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
VIDEO GRID (For Search/Categories)
|
|
||||||
============================================ */
|
|
||||||
.video-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(var(--card-width-desktop), 1fr));
|
|
||||||
gap: var(--card-gap);
|
|
||||||
padding: 0 var(--row-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
INTEREST CARDS (Quick Category Filters)
|
|
||||||
============================================ */
|
|
||||||
.interest-section {
|
|
||||||
padding: 24px var(--row-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
.interest-cards {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interest-cards::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interest-card {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border: 1px solid var(--netflix-border);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--netflix-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.interest-card:hover {
|
|
||||||
background: var(--netflix-text);
|
|
||||||
color: var(--netflix-bg);
|
|
||||||
border-color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.interest-card__icon {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
NETFLIX TOP 10 SECTION
|
|
||||||
============================================ */
|
|
||||||
.top10-section {
|
|
||||||
margin: var(--row-margin) 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top10-track {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0 var(--row-padding);
|
|
||||||
padding-bottom: 40px;
|
|
||||||
margin-bottom: -40px;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top10-track::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top10-item {
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top10-number {
|
|
||||||
font-size: 120px;
|
|
||||||
font-weight: 900;
|
|
||||||
line-height: 0.8;
|
|
||||||
color: var(--netflix-bg);
|
|
||||||
-webkit-text-stroke: 3px var(--netflix-text-muted);
|
|
||||||
margin-right: -30px;
|
|
||||||
z-index: 0;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top10-item .video-card {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SECTION TITLE STYLES
|
|
||||||
============================================ */
|
|
||||||
.section-title-apple {
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
color: var(--netflix-text);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-left: var(--row-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-more {
|
|
||||||
color: var(--netflix-text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
transition: color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-more:hover {
|
|
||||||
color: var(--netflix-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-section {
|
|
||||||
padding: 0 var(--row-padding) 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--card-gap);
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 16px var(--row-padding);
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-row::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-row .video-card {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: var(--card-width-desktop);
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
/* ============================================
|
|
||||||
KV-Stream - CSS Variables
|
|
||||||
PIXEL-PERFECT NETFLIX DESIGN TOKENS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* === Netflix Exact Color Palette === */
|
|
||||||
--netflix-bg: #141414;
|
|
||||||
--netflix-bg-card: #181818;
|
|
||||||
--netflix-bg-elevated: #232323;
|
|
||||||
--netflix-bg-header: rgba(20, 20, 20, 0);
|
|
||||||
--netflix-bg-header-scrolled: rgba(20, 20, 20, 0.95);
|
|
||||||
|
|
||||||
--netflix-red: #e50914;
|
|
||||||
--netflix-red-hover: #f40612;
|
|
||||||
--netflix-red-dark: #b20710;
|
|
||||||
|
|
||||||
--netflix-text: #ffffff;
|
|
||||||
--netflix-text-secondary: #b3b3b3;
|
|
||||||
--netflix-text-muted: #8c8c8c;
|
|
||||||
--netflix-text-dim: #666666;
|
|
||||||
|
|
||||||
--netflix-green: #46d369;
|
|
||||||
--netflix-border: rgba(255, 255, 255, 0.1);
|
|
||||||
|
|
||||||
/* Legacy compatibility aliases */
|
|
||||||
--color-bg-primary: var(--netflix-bg);
|
|
||||||
--color-bg-secondary: var(--netflix-bg-card);
|
|
||||||
--color-bg-tertiary: var(--netflix-bg-elevated);
|
|
||||||
--color-bg-elevated: var(--netflix-bg-elevated);
|
|
||||||
--color-bg-card: var(--netflix-bg-card);
|
|
||||||
--color-text-primary: var(--netflix-text);
|
|
||||||
--color-text-secondary: var(--netflix-text-secondary);
|
|
||||||
--color-text-tertiary: var(--netflix-text-muted);
|
|
||||||
--color-accent: var(--netflix-red);
|
|
||||||
--color-border: var(--netflix-border);
|
|
||||||
--apple-bg-primary: var(--netflix-bg);
|
|
||||||
--apple-text-primary: var(--netflix-text);
|
|
||||||
--apple-accent: var(--netflix-red);
|
|
||||||
|
|
||||||
/* === Netflix Card Specifications === */
|
|
||||||
--card-width-desktop: 200px;
|
|
||||||
--card-width-tablet: 160px;
|
|
||||||
--card-width-mobile: 110px;
|
|
||||||
--card-aspect-ratio: 2 / 3;
|
|
||||||
/* Portrait posters */
|
|
||||||
--card-gap: 8px;
|
|
||||||
--card-radius: 4px;
|
|
||||||
--card-hover-scale: 1.3;
|
|
||||||
--card-hover-delay: 300ms;
|
|
||||||
|
|
||||||
/* === Netflix Layout Specifications === */
|
|
||||||
--header-height: 68px;
|
|
||||||
--header-height-mobile: 48px;
|
|
||||||
--row-padding: 4%;
|
|
||||||
--row-margin: 3vw;
|
|
||||||
--mobile-nav-height: 56px;
|
|
||||||
|
|
||||||
/* === Netflix Typography (Netflix Sans fallback) === */
|
|
||||||
--font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
--font-heading: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
|
|
||||||
--font-size-xs: 11px;
|
|
||||||
--font-size-sm: 13px;
|
|
||||||
--font-size-base: 14px;
|
|
||||||
--font-size-lg: 16px;
|
|
||||||
--font-size-xl: 18px;
|
|
||||||
--font-size-2xl: 20px;
|
|
||||||
--font-size-3xl: 24px;
|
|
||||||
--font-size-4xl: 32px;
|
|
||||||
--font-size-hero: clamp(2rem, 4vw, 3.5rem);
|
|
||||||
|
|
||||||
--font-weight-regular: 400;
|
|
||||||
--font-weight-medium: 500;
|
|
||||||
--font-weight-semibold: 600;
|
|
||||||
--font-weight-bold: 700;
|
|
||||||
|
|
||||||
--line-height-tight: 1.1;
|
|
||||||
--line-height-normal: 1.4;
|
|
||||||
--line-height-relaxed: 1.6;
|
|
||||||
|
|
||||||
/* === Netflix Button Specs === */
|
|
||||||
--btn-height: 42px;
|
|
||||||
--btn-height-sm: 32px;
|
|
||||||
--btn-radius: 4px;
|
|
||||||
--btn-padding: 0 24px;
|
|
||||||
|
|
||||||
/* === Netflix Shadows === */
|
|
||||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.5);
|
|
||||||
--shadow-card-hover: 0 8px 32px rgba(0, 0, 0, 0.7);
|
|
||||||
--shadow-dropdown: 0 2px 10px rgba(0, 0, 0, 0.8);
|
|
||||||
--shadow-header: 0 0 10px rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
/* === Netflix Transitions === */
|
|
||||||
--transition-fast: 150ms ease;
|
|
||||||
--transition-base: 250ms ease;
|
|
||||||
--transition-card: 300ms cubic-bezier(0.2, 0, 0.2, 1);
|
|
||||||
--transition-hover-delay: 300ms;
|
|
||||||
|
|
||||||
/* === Z-Index Scale === */
|
|
||||||
--z-base: 0;
|
|
||||||
--z-card: 10;
|
|
||||||
--z-card-hover: 50;
|
|
||||||
--z-row: 20;
|
|
||||||
--z-header: 100;
|
|
||||||
--z-dropdown: 150;
|
|
||||||
--z-modal: 1000;
|
|
||||||
--z-mobile-nav: 200;
|
|
||||||
}
|
|
||||||
|
|
@ -40,16 +40,7 @@
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"@capacitor/status-bar": "/js/capacitor-mock.js",
|
|
||||||
"@capacitor/haptics": "/js/capacitor-mock.js",
|
|
||||||
"artplayer": "https://esm.sh/artplayer@5.1.7",
|
|
||||||
"hls.js": "https://esm.sh/hls.js@1.5.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -130,6 +121,18 @@
|
||||||
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
||||||
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@capacitor/status-bar": "/js/capacitor-mock.js",
|
||||||
|
"@capacitor/haptics": "/js/capacitor-mock.js",
|
||||||
|
"artplayer": "https://esm.sh/artplayer@5.1.7",
|
||||||
|
"hls.js": "https://esm.sh/hls.js@1.5.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module" crossorigin src="/assets/watch-C4cPirDv.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/keyboard-nav-CjQOo0Sk.js">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-x-hidden">
|
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-x-hidden">
|
||||||
|
|
@ -444,8 +447,6 @@
|
||||||
|
|
||||||
<!-- App Scripts -->
|
<!-- App Scripts -->
|
||||||
<script src="/js/history-service.js"></script>
|
<script src="/js/history-service.js"></script>
|
||||||
<script type="module" src="/scripts/search.js"></script>
|
|
||||||
<script type="module" src="/scripts/watch.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
25
frontend/js/capacitor-mock.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// Mock Capacitor Plugins for Browser/Dev Mode
|
||||||
|
export const StatusBar = {
|
||||||
|
setStyle: async () => { },
|
||||||
|
setBackgroundColor: async () => { },
|
||||||
|
show: async () => { },
|
||||||
|
hide: async () => { },
|
||||||
|
Style: { Dark: 'DARK', Light: 'LIGHT' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Style = { Dark: 'DARK', Light: 'LIGHT' };
|
||||||
|
|
||||||
|
export const Haptics = {
|
||||||
|
impact: async () => { },
|
||||||
|
vibrate: async () => { },
|
||||||
|
notification: async () => { },
|
||||||
|
selectionStart: async () => { },
|
||||||
|
selectionChanged: async () => { },
|
||||||
|
selectionEnd: async () => { }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImpactStyle = {
|
||||||
|
Heavy: 'HEAVY',
|
||||||
|
Medium: 'MEDIUM',
|
||||||
|
Light: 'LIGHT'
|
||||||
|
};
|
||||||
|
|
@ -80,6 +80,7 @@ export function createVideoCard(video, onPlay, onInfo) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'video-card';
|
card.className = 'video-card';
|
||||||
card.dataset.videoId = video.id;
|
card.dataset.videoId = video.id;
|
||||||
|
card.setAttribute('tabindex', '0'); // Enable D-pad focus for Android TV
|
||||||
|
|
||||||
// PERFORMANCE: Use backend image proxy for faster loading (WebP + Resized)
|
// PERFORMANCE: Use backend image proxy for faster loading (WebP + Resized)
|
||||||
// Use optimized sizes for mobile/desktop balance (quality vs speed)
|
// Use optimized sizes for mobile/desktop balance (quality vs speed)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export class KeyboardNavigation {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.currentFocus = null;
|
this.currentFocus = null;
|
||||||
this.isEnabled = false;
|
this.isEnabled = false;
|
||||||
|
this.isTVMode = this.detectTVMode();
|
||||||
|
|
||||||
// Selectors for focusable items
|
// Selectors for focusable items
|
||||||
this.selectors = [
|
this.selectors = [
|
||||||
|
|
@ -22,14 +23,25 @@ export class KeyboardNavigation {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect if running on Android TV or similar leanback device
|
||||||
|
detectTVMode() {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
return ua.includes('android') && (ua.includes('tv') || ua.includes('aftm') || ua.includes('aftt'));
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.isEnabled = true;
|
this.isEnabled = true;
|
||||||
document.addEventListener('keydown', this.handleKey.bind(this));
|
document.addEventListener('keydown', this.handleKey.bind(this));
|
||||||
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
|
||||||
|
|
||||||
// Initial focus?
|
// Only add mouse handler on non-TV devices
|
||||||
// Usually wait for user to press a key to enter "Keyboard Mode"
|
if (!this.isTVMode) {
|
||||||
// so we don't show focus rings to mouse users.
|
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-focus first card for TV mode (helps D-pad users start navigating)
|
||||||
|
if (this.isTVMode) {
|
||||||
|
setTimeout(() => this.focusFirstVisible(), 500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseMove() {
|
handleMouseMove() {
|
||||||
|
|
|
||||||
|
|
@ -491,12 +491,31 @@
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard Navigation */
|
/* Keyboard/D-pad Navigation Focus Styles */
|
||||||
.video-card.keyboard-focused {
|
.video-card.keyboard-focused,
|
||||||
|
.video-card:focus {
|
||||||
z-index: var(--z-card-hover);
|
z-index: var(--z-card-hover);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-card.keyboard-focused .video-card__container {
|
.video-card.keyboard-focused .video-card__container,
|
||||||
transform: scale(1.05);
|
.video-card:focus .video-card__container {
|
||||||
box-shadow: 0 0 0 3px var(--netflix-red), var(--shadow-card-hover);
|
transform: scale(1.08);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px var(--netflix-red),
|
||||||
|
0 0 30px rgba(229, 9, 20, 0.5),
|
||||||
|
var(--shadow-card-hover);
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TV Mode: Larger focus indicators for viewing distance */
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.video-card.keyboard-focused .video-card__container,
|
||||||
|
.video-card:focus .video-card__container {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 6px var(--netflix-red),
|
||||||
|
0 0 50px rgba(229, 9, 20, 0.6),
|
||||||
|
var(--shadow-card-hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||