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"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>StreamFlix - Download App</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||
<link rel="stylesheet" href="/styles/index.css">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo-DuxtXB_R.svg">
|
||||
<style>
|
||||
:root {
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
|
|
@ -197,6 +196,7 @@
|
|||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" crossorigin href="/assets/download-m6ZKmHFf.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -204,7 +204,7 @@
|
|||
<a href="/" class="back-link">← Back to StreamFlix</a>
|
||||
|
||||
<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>
|
||||
<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">
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="assets/apple-touch-icon.svg">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon-D7BKdTu2.svg">
|
||||
<link rel="apple-touch-icon" href="/assets/apple-touch-icon-CmxMqamG.svg">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
|
|
@ -44,16 +44,7 @@
|
|||
},
|
||||
}
|
||||
</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>
|
||||
:root {
|
||||
|
|
@ -218,13 +209,25 @@
|
|||
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
||||
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
||||
<script type="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>
|
||||
|
||||
<body class="bg-background-light dark:bg-background-dark text-white font-display overflow-x-hidden antialiased">
|
||||
|
||||
<!-- Splash Screen -->
|
||||
<div id="splash-screen">
|
||||
<img src="/assets/logo.svg" alt="StreamFlix" class="splash-logo">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMDAgNjAiPgogIDxkZWZzPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJnb2xkR3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdHlsZT0ic3RvcC1jb2xvcjojZTUwOTE0Ii8+CiAgICAgIDxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6I2IyMDcwZiIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPCEtLSBQbGF5IGJ1dHRvbiBpY29uIC0tPgogIDxjaXJjbGUgY3g9IjI1IiBjeT0iMzAiIHI9IjIyIiBmaWxsPSJ1cmwoI2dvbGRHcmFkaWVudCkiLz4KICA8cG9seWdvbiBwb2ludHM9IjIwLDIwIDIwLDQwIDM4LDMwIiBmaWxsPSIjMTQxNDE0Ii8+CiAgPCEtLSBUZXh0IC0tPgogIDx0ZXh0IHg9IjU1IiB5PSI0MCIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjI4IiBmb250LXdlaWdodD0iYm9sZCIgZmlsbD0iI2ZmZmZmZiI+U3RyZWFtPHRzcGFuIGZpbGw9InVybCgjZ29sZEdyYWRpZW50KSI+RmxpeDwvdHNwYW4+PC90ZXh0Pgo8L3N2Zz4K" alt="StreamFlix" class="splash-logo">
|
||||
<div class="loading-container">
|
||||
<div id="loading-bar"></div>
|
||||
</div>
|
||||
|
|
@ -241,7 +244,7 @@
|
|||
<div class="flex items-center gap-8">
|
||||
<!-- Logo -->
|
||||
<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>
|
||||
<!-- Desktop Nav Links -->
|
||||
<nav class="hidden md:flex items-center gap-6" id="mainNav">
|
||||
|
|
@ -464,9 +467,6 @@
|
|||
|
||||
<!-- Scripts -->
|
||||
<script src="/js/history-service.js"></script>
|
||||
<script type="module" src="/scripts/search.js"></script>
|
||||
<script type="module" src="/scripts/category-system.js"></script>
|
||||
<script type="module" src="/scripts/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -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 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>
|
||||
:root {
|
||||
|
|
@ -130,6 +121,18 @@
|
|||
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
||||
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
||||
<script type="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>
|
||||
|
||||
<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 -->
|
||||
<script src="/js/history-service.js"></script>
|
||||
<script type="module" src="/scripts/search.js"></script>
|
||||
<script type="module" src="/scripts/watch.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
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');
|
||||
card.className = 'video-card';
|
||||
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)
|
||||
// Use optimized sizes for mobile/desktop balance (quality vs speed)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export class KeyboardNavigation {
|
|||
constructor() {
|
||||
this.currentFocus = null;
|
||||
this.isEnabled = false;
|
||||
this.isTVMode = this.detectTVMode();
|
||||
|
||||
// Selectors for focusable items
|
||||
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() {
|
||||
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.
|
||||
// Only add mouse handler on non-TV devices
|
||||
if (!this.isTVMode) {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -491,12 +491,31 @@
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Keyboard Navigation */
|
||||
.video-card.keyboard-focused {
|
||||
/* Keyboard/D-pad Navigation Focus Styles */
|
||||
.video-card.keyboard-focused,
|
||||
.video-card:focus {
|
||||
z-index: var(--z-card-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.video-card.keyboard-focused .video-card__container {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 3px var(--netflix-red), var(--shadow-card-hover);
|
||||
.video-card.keyboard-focused .video-card__container,
|
||||
.video-card:focus .video-card__container {
|
||||
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);
|
||||
}
|
||||
}
|
||||