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
This commit is contained in:
Khoa.vo 2025-12-24 20:59:56 +07:00
parent b0bb0f02f3
commit 9d1d9bc741
49 changed files with 174 additions and 12102 deletions

View file

Before

Width:  |  Height:  |  Size: 543 B

After

Width:  |  Height:  |  Size: 543 B

File diff suppressed because one or more lines are too long

View file

Before

Width:  |  Height:  |  Size: 445 B

After

Width:  |  Height:  |  Size: 445 B

View file

@ -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

View file

Before

Width:  |  Height:  |  Size: 615 B

After

Width:  |  Height:  |  Size: 615 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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};

View file

@ -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

View file

@ -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>

View file

@ -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();

View file

@ -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
};
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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));
});
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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
}
};

View file

@ -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();

View file

@ -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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -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();
}

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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%;
}

View file

@ -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);
}

View file

@ -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;
}

File diff suppressed because it is too large Load diff

View file

@ -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>

View 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'
};

View file

@ -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)

View file

@ -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() {

View file

@ -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);
}
}