(null);
+
+ // Drag to scroll logic state
+ const [isDragging, setIsDragging] = useState(false);
+
+ // Drag to scroll logic state refs
+ const isDown = useRef(false);
+ const startX = useRef(0);
+ const scrollLeft = useRef(0);
+
+ useEffect(() => {
+ const fetchMovies = async () => {
+ // If manual movies are provided (e.g. History, My List), use them directly
+ if (manualMovies) {
+ let result = manualMovies;
+ if (limit && result.length > 0) {
+ result = result.slice(0, limit);
+ }
+ setMovies(result);
+ setLoading(false);
+ return;
+ }
+
+ try {
+ let endpoint = '';
+ if (searchQuery) { // ... unchanged fetch logic
+ endpoint = `/api/videos/search?q=${encodeURIComponent(searchQuery)}`;
+ } else if (category && category !== 'home') {
+ endpoint = `/api/videos/home?category=${category}`;
+ } else {
+ endpoint = '/api/videos/home';
+ }
+
+ const res = await fetch(endpoint);
+ const data = await res.json();
+ let result = data || [];
+
+ // Search API usually returns unfiltered list, so we might need to be careful.
+ // But generally it returns an array of movies.
+
+ if (limit && result.length > 0) {
+ result = result.slice(0, limit);
+ }
+ setMovies(result);
+ } catch (err) {
+ console.error(`Failed to fetch movies for row ${title}`, err);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchMovies();
+ }, [category, searchQuery, limit, manualMovies]);
+
+ const scroll = (direction: 'left' | 'right') => {
+ if (rowRef.current) {
+ const { current } = rowRef;
+ const scrollAmount = direction === 'left' ? -current.clientWidth * 0.8 : current.clientWidth * 0.8;
+ current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
+ }
+ };
+
+ if (loading) return (
+
+
+ {layout === 'row' ? (
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+ ) : (
+
+ {[...Array(10)].map((_, i) => (
+
+ ))}
+
+ )}
+
+ );
+
+ // Drag to scroll logic handlers
+
+ // Drag to scroll logic handlers
+
+ const handlePointerDown = (e: React.PointerEvent) => {
+ // Only enable custom drag for mouse. Touch uses native browser scroll.
+ if (e.pointerType !== 'mouse' || !rowRef.current) return;
+
+ isDown.current = true;
+ startX.current = e.pageX - rowRef.current.offsetLeft;
+ scrollLeft.current = rowRef.current.scrollLeft;
+
+ // Capture pointer to track drag even if it leaves the element
+ e.currentTarget.setPointerCapture(e.pointerId);
+ };
+
+ const handlePointerUp = (e: React.PointerEvent) => {
+ if (!isDown.current) return;
+
+ isDown.current = false;
+ if (isDragging) {
+ setIsDragging(false);
+ }
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ };
+
+ const handlePointerMove = (e: React.PointerEvent) => {
+ if (!isDown.current || !rowRef.current) return;
+
+ e.preventDefault();
+ const x = e.pageX - rowRef.current.offsetLeft;
+ const walk = (x - startX.current) * 2; // Scroll-fast
+
+ // Only trigger dragging state if moved significantly to prevent accidental clicks being blocked
+ if (Math.abs(x - startX.current) > 5 && !isDragging) {
+ setIsDragging(true);
+ }
+
+ rowRef.current.scrollLeft = scrollLeft.current - walk;
+ };
+
+
+
+ if (movies.length === 0) return null;
+
+ return (
+
+
+
+ {title}
+
+ Xem tất cả
+
+
+
+ {layout === 'row' ? (
+
+
+
+
+ {movies.map((movie) => (
+
+
+

+
+
+ {movie.quality && (
+
+ {movie.quality}
+
+ )}
+ {movie.lang && (
+
+ {movie.lang}
+
+ )}
+
+ {movie.time && (
+
+ {movie.time}
+
+ )}
+
+
+
+ {movie.title}
+
+
+
+ ))}
+
+
+
+
+ ) : (
+
+ {movies.map((movie) => (
+
+
+

+
+
+ {movie.quality && (
+
+ {movie.quality}
+
+ )}
+
+
+
+
+ {movie.title}
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default MovieRow;
diff --git a/frontend-react/src/components/Navbar.tsx b/frontend-react/src/components/Navbar.tsx
new file mode 100644
index 0000000..15a99ab
--- /dev/null
+++ b/frontend-react/src/components/Navbar.tsx
@@ -0,0 +1,112 @@
+import React, { useState } from 'react';
+import { Link, useNavigate, useLocation } from 'react-router-dom';
+import { Search, Film, Menu, X } from 'lucide-react';
+import { NAV_ITEMS } from '../constants'; // Unified Categories
+
+const Navbar = () => {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ // Helper to check active state
+ const isActive = (path: string) => {
+ if (path === '/') return location.pathname === '/' && !location.search;
+ return location.pathname + location.search === path;
+ };
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (searchQuery.trim()) {
+ navigate(`/?q=${encodeURIComponent(searchQuery)}`);
+ setIsMenuOpen(false);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default Navbar;
diff --git a/frontend-react/src/components/SettingsPanel.tsx b/frontend-react/src/components/SettingsPanel.tsx
new file mode 100644
index 0000000..a2e7979
--- /dev/null
+++ b/frontend-react/src/components/SettingsPanel.tsx
@@ -0,0 +1,88 @@
+import { useState } from 'react';
+import { Settings, X, Check } from 'lucide-react';
+import { useTheme } from '../context/ThemeContext';
+import type { ThemeName } from '../types/Theme';
+
+export const SettingsPanel = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { currentTheme, setTheme } = useTheme();
+
+ const themes: { id: ThemeName; name: string; color: string }[] = [
+ { id: 'default', name: 'StreamFlow', color: '#06b6d4' },
+ { id: 'netflix', name: 'Netflix', color: '#E50914' },
+ { id: 'apple', name: 'Apple TV+', color: '#FFFFFF' },
+ ];
+
+ return (
+ <>
+
+
+ {isOpen && (
+
+
setIsOpen(false)}
+ />
+
+
+
+
Appearance
+
+
+
+
+
+
Choose Theme
+
+ {themes.map((theme) => (
+
+ ))}
+
+
+
+
+
+ Switching themes completely changes the layout and browsing experience.
+
+
+
+
+
+ )}
+ >
+ );
+};
diff --git a/frontend-react/src/constants.ts b/frontend-react/src/constants.ts
new file mode 100644
index 0000000..30180f9
--- /dev/null
+++ b/frontend-react/src/constants.ts
@@ -0,0 +1,14 @@
+import { Home, Film, Tv, PlayCircle, Heart, Folder } from 'lucide-react';
+
+export const CATEGORIES = [
+ { id: 'phim-le', name: 'Phim Lẻ', path: '?category=phim-le', icon: Film },
+ { id: 'phim-bo', name: 'Phim Bá»™', path: '?category=phim-bo', icon: Tv },
+ { id: 'hoat-hinh', name: 'Hoạt Hình', path: '?category=hoat-hinh', icon: PlayCircle },
+ { id: 'tv-shows', name: 'TV Shows', path: '?category=tv-shows', icon: Folder },
+ { id: 'my-list', name: 'My List', path: '/my-list', icon: Heart },
+];
+
+export const NAV_ITEMS = [
+ { name: 'Home', path: '/', icon: Home },
+ ...CATEGORIES.map(cat => ({ name: cat.name, path: cat.path, icon: cat.icon })),
+];
diff --git a/frontend-react/src/context/ThemeContext.tsx b/frontend-react/src/context/ThemeContext.tsx
new file mode 100644
index 0000000..9fe6e4e
--- /dev/null
+++ b/frontend-react/src/context/ThemeContext.tsx
@@ -0,0 +1,43 @@
+import React, { createContext, useContext, useState, useEffect } from 'react';
+import type { ThemeName } from '../types/Theme';
+
+// We will import the actual theme objects here once they are created
+// import { netflixTheme } from '../themes/netflix';
+// import { appleTheme } from '../themes/apple';
+
+interface ThemeContextType {
+ currentTheme: ThemeName;
+ setTheme: (theme: ThemeName) => void;
+
+ // For now, we'll just store the ID. Later we will expose the full theme object
+ // theme: Theme;
+}
+
+const ThemeContext = createContext
(undefined);
+
+export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [currentTheme, setCurrentTheme] = useState(() => {
+ const saved = localStorage.getItem('app-theme');
+ return (saved as ThemeName) || 'netflix';
+ });
+
+ useEffect(() => {
+ localStorage.setItem('app-theme', currentTheme);
+ // We can also set a class on the body if global styles need it
+ document.body.className = `theme-${currentTheme}`;
+ }, [currentTheme]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useTheme = () => {
+ const context = useContext(ThemeContext);
+ if (context === undefined) {
+ throw new Error('useTheme must be used within a ThemeProvider');
+ }
+ return context;
+};
diff --git a/frontend-react/src/hooks/useMovies.ts b/frontend-react/src/hooks/useMovies.ts
new file mode 100644
index 0000000..8555a1d
--- /dev/null
+++ b/frontend-react/src/hooks/useMovies.ts
@@ -0,0 +1,49 @@
+import { useState, useEffect } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import type { Movie } from '../types';
+
+export const useMovies = () => {
+ const [movies, setMovies] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searchParams] = useSearchParams();
+ const query = searchParams.get('q');
+ const category = searchParams.get('category');
+
+ useEffect(() => {
+ const fetchMovies = async () => {
+ setLoading(true);
+ try {
+ let endpoint = '/api/videos/home';
+ if (query) {
+ endpoint = `/api/videos/search?q=${query}`;
+ } else if (category && category !== 'home') {
+ endpoint = `/api/videos/home?category=${category}`;
+ }
+
+ const res = await fetch(endpoint);
+ if (!res.ok) {
+ throw new Error(`HTTP error! status: ${res.status}`);
+ }
+ const data = await res.json();
+ setMovies(data || []);
+ } catch (err) {
+ console.error("Failed to fetch movies", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchMovies();
+ }, [query, category]);
+
+ const getTitle = () => {
+ if (query) return `Results for "${query}"`;
+ if (category === 'phim-le') return 'Movies';
+ if (category === 'phim-bo') return 'Series';
+ if (category === 'hoat-hinh') return 'Cartoons';
+ if (category === 'tv-shows') return 'TV Shows';
+ return 'Latest Movies';
+ };
+
+ return { movies, loading, title: getTitle() };
+};
diff --git a/frontend-react/src/hooks/useMyList.ts b/frontend-react/src/hooks/useMyList.ts
new file mode 100644
index 0000000..2cb2f49
--- /dev/null
+++ b/frontend-react/src/hooks/useMyList.ts
@@ -0,0 +1,61 @@
+import { useState, useEffect } from 'react';
+import type { Movie } from '../types';
+
+interface MyListState {
+ saved: Movie[];
+ history: Movie[];
+}
+
+export const useMyList = () => {
+ const [list, setList] = useState(() => {
+ const saved = localStorage.getItem('streamflow_mylist');
+ return saved ? JSON.parse(saved) : { saved: [], history: [] };
+ });
+
+ useEffect(() => {
+ localStorage.setItem('streamflow_mylist', JSON.stringify(list));
+ }, [list]);
+
+ const addToList = (movie: Movie) => {
+ setList(prev => {
+ if (prev.saved.some(m => m.id === movie.id)) return prev;
+ return { ...prev, saved: [movie, ...prev.saved] };
+ });
+ };
+
+ const removeFromList = (movieId: string) => {
+ setList(prev => ({
+ ...prev,
+ saved: prev.saved.filter(m => m.id !== movieId)
+ }));
+ };
+
+ const addToHistory = (movie: Movie) => {
+ setList(prev => {
+ const filtered = prev.history.filter(m => m.id !== movie.id);
+
+ // Normalize Category to ensure it works with Recommendations
+ let cat = movie.category?.toLowerCase() || 'phim-le';
+ if (cat === 'movies') cat = 'phim-le';
+ if (cat === 'series') cat = 'phim-bo';
+ if (cat === 'animation') cat = 'hoat-hinh';
+ if (cat === 'cartoon') cat = 'hoat-hinh';
+ if (cat === 'tv') cat = 'tv-shows';
+
+ const normalizedMovie = { ...movie, category: cat };
+
+ return { ...prev, history: [normalizedMovie, ...filtered].slice(0, 50) };
+ });
+ };
+
+ const isSaved = (movieId: string) => list.saved.some(m => m.id === movieId);
+
+ return {
+ savedMovies: list.saved,
+ watchHistory: list.history,
+ addToList,
+ removeFromList,
+ addToHistory,
+ isSaved
+ };
+};
diff --git a/frontend-react/src/hooks/useSmartRecommendations.ts b/frontend-react/src/hooks/useSmartRecommendations.ts
new file mode 100644
index 0000000..10ce297
--- /dev/null
+++ b/frontend-react/src/hooks/useSmartRecommendations.ts
@@ -0,0 +1,64 @@
+import { useMemo } from 'react';
+import type { Movie } from '../types';
+import { CATEGORIES } from '../constants';
+
+interface Recommendation {
+ id: string;
+ title: string;
+ category: string;
+ reason: string;
+}
+
+export const useSmartRecommendations = (history: Movie[]): Recommendation[] => {
+ return useMemo(() => {
+ if (!history || history.length === 0) return [];
+
+ // Pre-defined mapping for data normalization
+ const NORMALIZE_MAP: Record = {
+ 'movies': 'phim-le',
+ 'phim-le': 'phim-le',
+ 'series': 'phim-bo',
+ 'phim-bo': 'phim-bo',
+ 'cartoon': 'hoat-hinh',
+ 'animation': 'hoat-hinh',
+ 'hoat-hinh': 'hoat-hinh',
+ 'tv-shows': 'tv-shows',
+ 'tv': 'tv-shows',
+ 'shows': 'tv-shows'
+ };
+
+ // 1. Frequency Map of Categories
+ const categoryCounts: Record = {};
+ history.forEach(movie => {
+ if (movie.category) {
+ const raw = movie.category.toLowerCase();
+ const normalized = NORMALIZE_MAP[raw] || (CATEGORIES.some(c => c.id === raw) ? raw : 'phim-le');
+
+ if (CATEGORIES.some(c => c.id === normalized)) {
+ categoryCounts[normalized] = (categoryCounts[normalized] || 0) + 1;
+ }
+ }
+ });
+
+ // 2. Sort by frequency
+ const sortedCategories = Object.entries(categoryCounts)
+ .sort(([, a], [, b]) => b - a)
+ .map(([cat]) => cat);
+
+ // 3. Get Top 2 Categories
+ const topCategories = sortedCategories.slice(0, 2);
+
+ // 4. Map to Recommendation Objects
+ const recommendations: Recommendation[] = topCategories.map(catSlug => {
+ const catName = CATEGORIES.find(c => c.id === catSlug)?.name || 'Phim';
+ return {
+ id: `rec-${catSlug}`,
+ title: `Gợi ý từ ${catName}`,
+ category: catSlug,
+ reason: `Based on your interest in ${catName}`
+ };
+ });
+
+ return recommendations;
+ }, [history]);
+};
diff --git a/frontend-react/src/hooks/useWatchMovie.ts b/frontend-react/src/hooks/useWatchMovie.ts
new file mode 100644
index 0000000..d906a13
--- /dev/null
+++ b/frontend-react/src/hooks/useWatchMovie.ts
@@ -0,0 +1,93 @@
+import { useState, useEffect, useRef } from 'react';
+import Hls from 'hls.js';
+import type { MovieDetail, VideoSource } from '../types';
+
+export const useWatchMovie = (slug: string | undefined, episode: string | undefined) => {
+ const videoRef = useRef(null);
+ const [movie, setMovie] = useState(null);
+ const [source, setSource] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [currentEpisode, setCurrentEpisode] = useState(parseInt(episode || '1'));
+
+ useEffect(() => {
+ if (!slug) return;
+ const fetchDetails = async () => {
+ try {
+ const res = await fetch(`/api/videos/${slug}`);
+ if (!res.ok) throw new Error('Failed to fetch details');
+ const data = await res.json();
+ setMovie(data);
+ } catch (err) {
+ console.error("Failed to fetch details", err);
+ }
+ };
+ fetchDetails();
+ }, [slug]);
+
+ useEffect(() => {
+ if (!movie) return;
+
+ const fetchStream = async () => {
+ setLoading(true);
+ try {
+ const ep = movie.episodes?.find(e => e.number === currentEpisode);
+
+ if (ep && (ep.url.includes('.m3u8') || ep.url.includes('index.m3u8'))) {
+ setSource({
+ stream_url: ep.url,
+ resolution: 'HD',
+ format_id: 'hls'
+ });
+ setLoading(false);
+ return;
+ }
+
+ const targetUrl = ep ? ep.url : `https://phimmoichill.network/xem-phim/${slug}/tap-${currentEpisode}`;
+
+ const res = await fetch(`/api/extract`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url: targetUrl }) // Changed to JSON payload
+ });
+
+ if (!res.ok) throw new Error('Failed to extract');
+ const data = await res.json();
+ setSource(data);
+ } catch (err) {
+ console.error("Failed to extract stream", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchStream();
+ }, [movie, currentEpisode, slug]);
+
+ useEffect(() => {
+ if (source && videoRef.current) {
+ if (Hls.isSupported()) {
+ const hls = new Hls();
+ hls.loadSource(source.stream_url);
+ hls.attachMedia(videoRef.current);
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
+ videoRef.current?.play();
+ });
+ return () => {
+ hls.destroy();
+ };
+ } else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
+ videoRef.current.src = source.stream_url;
+ videoRef.current.play();
+ }
+ }
+ }, [source]);
+
+ return {
+ movie,
+ source,
+ loading,
+ currentEpisode,
+ setCurrentEpisode,
+ videoRef
+ };
+};
diff --git a/frontend-react/src/index.css b/frontend-react/src/index.css
new file mode 100644
index 0000000..dd8af38
--- /dev/null
+++ b/frontend-react/src/index.css
@@ -0,0 +1,22 @@
+@import "tailwindcss";
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #000000;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+}
\ No newline at end of file
diff --git a/frontend-react/src/main.tsx b/frontend-react/src/main.tsx
new file mode 100644
index 0000000..bef5202
--- /dev/null
+++ b/frontend-react/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/frontend-react/src/pages/Home.tsx b/frontend-react/src/pages/Home.tsx
new file mode 100644
index 0000000..43b0b23
--- /dev/null
+++ b/frontend-react/src/pages/Home.tsx
@@ -0,0 +1,23 @@
+import { useTheme } from '../context/ThemeContext';
+import { netflixTheme } from '../themes/netflix';
+import { appleTheme } from '../themes/apple';
+
+import { defaultTheme } from '../themes/default';
+
+const themes = {
+ default: defaultTheme,
+ netflix: netflixTheme,
+ apple: appleTheme,
+};
+
+const Home = () => {
+ const { currentTheme } = useTheme();
+
+ // Dynamically select the Home component based on the current theme
+ const ActiveTheme = themes[currentTheme];
+ const ThemeHome = ActiveTheme.components.Home;
+
+ return ;
+};
+
+export default Home;
diff --git a/frontend-react/src/pages/MyList.tsx b/frontend-react/src/pages/MyList.tsx
new file mode 100644
index 0000000..317f821
--- /dev/null
+++ b/frontend-react/src/pages/MyList.tsx
@@ -0,0 +1,46 @@
+import { useTheme } from '../context/ThemeContext';
+import { netflixTheme } from '../themes/netflix';
+import { appleTheme } from '../themes/apple';
+import { useMyList } from '../hooks/useMyList';
+import { SettingsPanel } from '../components/SettingsPanel';
+
+import { defaultTheme } from '../themes/default';
+
+const themes = {
+ netflix: netflixTheme,
+ apple: appleTheme,
+ default: defaultTheme,
+};
+
+const MyList = () => {
+ const { currentTheme } = useTheme();
+ const { savedMovies, watchHistory } = useMyList();
+ const ActiveTheme = themes[currentTheme];
+ const { Layout, MovieGrid } = ActiveTheme.components;
+
+ return (
+
+
+ {/* Watch History Section */}
+ {watchHistory.length > 0 && (
+
+
+
+ )}
+
+ {/* Saved List Section */}
+
+
+ {savedMovies.length === 0 && watchHistory.length === 0 && (
+
+
Your list is empty.
+
Start watching or add movies to your list.
+
+ )}
+
+
+
+ );
+};
+
+export default MyList;
diff --git a/frontend-react/src/pages/Watch.tsx b/frontend-react/src/pages/Watch.tsx
new file mode 100644
index 0000000..3da7d1c
--- /dev/null
+++ b/frontend-react/src/pages/Watch.tsx
@@ -0,0 +1,58 @@
+import { useEffect } from 'react';
+import { useParams } from 'react-router-dom';
+import { useTheme } from '../context/ThemeContext';
+import { netflixTheme } from '../themes/netflix';
+import { appleTheme } from '../themes/apple';
+import { useMyList } from '../hooks/useMyList';
+
+import { defaultTheme } from '../themes/default';
+
+const themes = {
+ netflix: netflixTheme,
+ apple: appleTheme,
+ default: defaultTheme,
+};
+
+const Watch = () => {
+ const { slug, episode } = useParams();
+ const { currentTheme } = useTheme();
+ const { addToHistory } = useMyList();
+
+ // Fetch movie detail to get info for history
+ useEffect(() => {
+ if (!slug) return;
+ const fetchDetail = async () => {
+ try {
+ const res = await fetch(`/api/videos/${slug}`);
+ if (res.ok) {
+ const data = await res.json();
+ // Add to history when loaded
+ addToHistory({
+ id: data.id,
+ title: data.title,
+ original_title: data.original_title,
+ slug: data.slug,
+ thumbnail: data.thumbnail,
+ backdrop: data.backdrop,
+ year: data.year,
+ category: data.category || 'movies',
+ quality: data.quality,
+ director: data.director,
+ cast: data.cast
+ });
+ }
+ } catch (err) {
+ console.error("Failed to fetch for history", err);
+ }
+ };
+ fetchDetail();
+ }, [slug]);
+
+ // Select the current theme components
+ const ActiveTheme = themes[currentTheme];
+ const { WatchPage } = ActiveTheme.components;
+
+ return ;
+};
+
+export default Watch;
diff --git a/frontend-react/src/themes/apple/AppleHome.tsx b/frontend-react/src/themes/apple/AppleHome.tsx
new file mode 100644
index 0000000..35a9a01
--- /dev/null
+++ b/frontend-react/src/themes/apple/AppleHome.tsx
@@ -0,0 +1,15 @@
+import { Layout } from './Layout';
+import { HomeContent } from '../../components/HomeContent';
+import { SettingsPanel } from '../../components/SettingsPanel';
+
+export const AppleHome = () => {
+ return (
+
+ {/* Apple Theme usually has a dark gradient header, but HomeContent handles general layout */}
+
+
+
+
+
+ );
+};
diff --git a/frontend-react/src/themes/apple/Card.tsx b/frontend-react/src/themes/apple/Card.tsx
new file mode 100644
index 0000000..64a5053
--- /dev/null
+++ b/frontend-react/src/themes/apple/Card.tsx
@@ -0,0 +1,41 @@
+import type { Movie } from '../../types';
+import { Play } from 'lucide-react';
+
+export const Card = ({ movie }: { movie: Movie }) => {
+ return (
+
+ );
+};
diff --git a/frontend-react/src/themes/apple/Hero.tsx b/frontend-react/src/themes/apple/Hero.tsx
new file mode 100644
index 0000000..bb6aa7a
--- /dev/null
+++ b/frontend-react/src/themes/apple/Hero.tsx
@@ -0,0 +1,86 @@
+import { useState, useEffect } from 'react';
+import { Plus, Check, Play } from 'lucide-react';
+import type { Movie } from '../../types';
+import { useMyList } from '../../hooks/useMyList';
+
+export const Hero = ({ movies }: { movies: Movie[] }) => {
+ const [index, setIndex] = useState(0);
+ const { addToList, removeFromList, isSaved } = useMyList();
+
+ useEffect(() => {
+ if (movies.length <= 1) return;
+ const interval = setInterval(() => {
+ setIndex((prev) => (prev + 1) % movies.length);
+ }, 8000);
+ return () => clearInterval(interval);
+ }, [movies]);
+
+ if (!movies || movies.length === 0) return null;
+
+ const movie = movies[index];
+ const saved = isSaved(movie.id);
+
+ const toggleList = () => {
+ if (saved) removeFromList(movie.id);
+ else addToList(movie);
+ };
+
+ return (
+
+
+

+
+
+
+
+
+
+
+ Premiere
+
+
+
+ {movie.title}
+
+
+ {movie.original_title && (
+
{movie.original_title}
+ )}
+
+
+
+
+ Play
+
+
+
+
+
+
+ {/* Carousel Dots */}
+
+ {movies.map((_, i) => (
+
+
+ );
+};
diff --git a/frontend-react/src/themes/apple/Layout.tsx b/frontend-react/src/themes/apple/Layout.tsx
new file mode 100644
index 0000000..7139a8c
--- /dev/null
+++ b/frontend-react/src/themes/apple/Layout.tsx
@@ -0,0 +1,129 @@
+import { useState, useEffect } from 'react';
+import type { ReactNode } from 'react';
+import { Link, useNavigate, useLocation } from 'react-router-dom';
+import { Search, Apple, Home, Film, Tv, Sparkles, MonitorPlay } from 'lucide-react';
+import { CATEGORIES } from '../../constants';
+
+export const Layout = ({ children }: { children: ReactNode }) => {
+ const [scrolled, setScrolled] = useState(false);
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ useEffect(() => {
+ const handleScroll = () => {
+ setScrolled(window.scrollY > 20);
+ };
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (searchQuery.trim()) {
+ navigate(`/?q=${encodeURIComponent(searchQuery)}`);
+ setIsSearchOpen(false);
+ }
+ };
+
+ return (
+
+ {/* Glass Navbar */}
+
+
+ {/* Mobile Bottom Nav */}
+
+
+
+ {children}
+
+
+ );
+};
diff --git a/frontend-react/src/themes/apple/MovieGrid.tsx b/frontend-react/src/themes/apple/MovieGrid.tsx
new file mode 100644
index 0000000..da58f63
--- /dev/null
+++ b/frontend-react/src/themes/apple/MovieGrid.tsx
@@ -0,0 +1,32 @@
+import type { Movie } from '../../types';
+import { Card } from './Card';
+
+export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => {
+ if (loading) {
+ return (
+
+ {title &&
{title}
}
+
+ {[...Array(10)].map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+ {title &&
{title}
}
+
+
+
+
+ {movies.map((movie) => (
+
+ ))}
+
+
+ );
+};
diff --git a/frontend-react/src/themes/apple/WatchPage.tsx b/frontend-react/src/themes/apple/WatchPage.tsx
new file mode 100644
index 0000000..844311c
--- /dev/null
+++ b/frontend-react/src/themes/apple/WatchPage.tsx
@@ -0,0 +1,162 @@
+import { useNavigate } from 'react-router-dom';
+import { ArrowLeft, ChevronDown, Play, ChevronUp } from 'lucide-react';
+import { useWatchMovie } from '../../hooks/useWatchMovie';
+import { useState } from 'react';
+import MovieRow from '../../components/MovieRow';
+
+export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => {
+ const navigate = useNavigate();
+ const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode);
+ const [expanded, setExpanded] = useState(false);
+
+ if (!movie) return Loading...
;
+
+ const episodes = movie.episodes || [];
+
+ return (
+
+ {/* Navigation */}
+
+
+
+
+
+ {/* Player Section - Sticky on larger screens for cinema feel */}
+
+ {loading && (
+
+ )}
+ {(() => {
+ const activeEpisode = movie.episodes?.find(e => e.number === currentEpisode);
+ if (!activeEpisode?.url) {
+ return (
+
+
+
+
Processing Content
+
+ This title is currently being prepared for streaming.
+
+
+ {/* Subtle Background */}
+
+
+ );
+ }
+
+ return (
+
+ );
+ })()}
+
+
+ {/* Content Section - Scrolls over the bottom of the player if sticky, or just below */}
+
+
+ {/* Movie Info */}
+
+
+
{movie.title}
+
+ HD
+ {movie.year || '2024'}
+ {movie.episodes?.length || 0} Episodes
+
+
+
{movie.description}
+
+
+ {/* Episodes Grid */}
+
+
+
Episodes
+ {episodes.length} available
+
+
+
+ {(expanded ? episodes : episodes.slice(0, 20)).map((ep) => (
+
+ ))}
+
+
+ {episodes.length > 20 && (
+
+ )}
+
+
+ {/* Related Categories */}
+
+
+
More Like This
+
+
+
+
+
Trending Now
+
+
+
+
+
Top Movies
+
+
+
+
+
Animation
+
+
+
+
+
+
+ );
+};
diff --git a/frontend-react/src/themes/apple/index.ts b/frontend-react/src/themes/apple/index.ts
new file mode 100644
index 0000000..5d49f9e
--- /dev/null
+++ b/frontend-react/src/themes/apple/index.ts
@@ -0,0 +1,25 @@
+import type { Theme } from '../../types/Theme';
+import { Layout } from './Layout';
+import { Hero } from '../../components/Hero';
+import { MovieGrid } from './MovieGrid';
+import { Card } from './Card';
+import { WatchPage } from './WatchPage'; // Added
+import { AppleHome } from './AppleHome'; // Added
+
+export const appleTheme: Theme = {
+ name: 'apple',
+ label: 'Apple TV+',
+ colors: {
+ background: '#000000',
+ primary: '#FFFFFF',
+ text: '#FFFFFF',
+ },
+ components: {
+ Layout,
+ Hero,
+ MovieGrid,
+ Card,
+ WatchPage, // Added
+ Home: AppleHome, // Added as Home
+ },
+};
diff --git a/frontend-react/src/themes/default/DefaultHome.tsx b/frontend-react/src/themes/default/DefaultHome.tsx
new file mode 100644
index 0000000..d0ddcf8
--- /dev/null
+++ b/frontend-react/src/themes/default/DefaultHome.tsx
@@ -0,0 +1,15 @@
+import Navbar from '../../components/Navbar';
+import { HomeContent } from '../../components/HomeContent';
+import { SettingsPanel } from '../../components/SettingsPanel';
+
+export const DefaultHome = () => {
+ return (
+
+ );
+};
diff --git a/frontend-react/src/themes/default/WatchPage.tsx b/frontend-react/src/themes/default/WatchPage.tsx
new file mode 100644
index 0000000..d9ef828
--- /dev/null
+++ b/frontend-react/src/themes/default/WatchPage.tsx
@@ -0,0 +1,255 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ArrowLeft, Play, ChevronDown, ChevronUp } from 'lucide-react';
+import { useWatchMovie } from '../../hooks/useWatchMovie';
+import MovieRow from '../../components/MovieRow';
+
+export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => {
+ const navigate = useNavigate();
+ const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode);
+ const [expanded, setExpanded] = useState(false);
+
+ if (!movie) return (
+
+
+
+
Loading StreamFlow...
+
+
+ );
+
+ // Helper for URL safety (same as Hero)
+ const getImageUrl = (url: string | undefined, width: number) => {
+ if (!url) return '';
+ const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com');
+ return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl)}&w=${width}&output=webp`;
+ };
+
+ const episodes = movie.episodes || [];
+ const visibleEpisodes = expanded ? episodes : episodes.slice(0, 20);
+
+ return (
+
+ {/* Header / Navigation Overlay */}
+
+
+
+
+ {/* Main Layout Container */}
+
+ {/* Sidebar / Metadata Panel */}
+
+
+ {/* Movie Header */}
+
+
{movie.title}
+
+
+ {movie.quality || 'HD'}
+
+ {movie.year || '2024'}
+
+ 98% Match
+
+ {movie.original_title}
+
+
+
+ {/* Description */}
+
+
+ {/* Episodes Grid */}
+ {episodes.length > 0 && (
+
+
+
+
+ Episodes
+
+
+ {episodes.length} Items
+
+
+
+
+ {visibleEpisodes.map((ep) => (
+
+ ))}
+
+
+ {episodes.length > 20 && (
+
+ )}
+
+ )}
+
+ {/* Related Content Section */}
+
+ {/* More Like This */}
+
+
Có thể bạn sẽ thÃch
+
+
+
+ {/* Trending */}
+
+
Phim Má»›i Cáºp Nháºt
+
+
+
+ {/* Top Movies */}
+
+
Top Phim Lẻ
+
+
+
+ {/* Top Series */}
+
+
Top Phim Bá»™
+
+
+
+ {/* Animation */}
+
+
Hoạt Hình Hot
+
+
+
+ {/* TV Shows */}
+
+
TV Shows
+
+
+
+
+
+
+ {/* Main Content Area (Player) */}
+
+ {/* Ambient Background Gradient behind player */}
+
+
+ {loading && (
+
+ )}
+
+ {(() => {
+ const activeEpisode = movie.episodes?.find(e => e.number === currentEpisode);
+ if (!activeEpisode?.url) {
+ return (
+
+
+
+
Coming Soon
+
+ The server is currently processing the movie "{movie.title}". Please check back later for the upload.
+
+
+ {/* Ambient background from poster */}
+
+
+ );
+ }
+
+ return (
+
+ );
+ })()}
+
+
+
+ );
+};
diff --git a/frontend-react/src/themes/default/index.ts b/frontend-react/src/themes/default/index.ts
new file mode 100644
index 0000000..2b1773b
--- /dev/null
+++ b/frontend-react/src/themes/default/index.ts
@@ -0,0 +1,25 @@
+import type { Theme } from '../../types/Theme';
+import { DefaultHome } from './DefaultHome';
+import { Hero } from '../../components/Hero';
+import { MovieGrid } from '../netflix/MovieGrid';
+import { Card } from '../netflix/Card';
+import { WatchPage } from './WatchPage'; // Use local StreamFlow WatchPage
+import { Layout } from '../netflix/Layout'; // Fallback layout if needed, but Home handles it
+
+export const defaultTheme: Theme = {
+ name: 'default',
+ label: 'StreamFlow',
+ colors: {
+ background: '#141414',
+ primary: '#E50914',
+ text: '#FFFFFF',
+ },
+ components: {
+ Layout,
+ Hero,
+ MovieGrid,
+ Card,
+ WatchPage,
+ Home: DefaultHome,
+ },
+};
diff --git a/frontend-react/src/themes/netflix/Card.tsx b/frontend-react/src/themes/netflix/Card.tsx
new file mode 100644
index 0000000..f04233c
--- /dev/null
+++ b/frontend-react/src/themes/netflix/Card.tsx
@@ -0,0 +1,41 @@
+import { Play } from 'lucide-react';
+import type { Movie } from '../../types';
+
+export const Card = ({ movie }: { movie: Movie }) => {
+ return (
+
+ );
+};
diff --git a/frontend-react/src/themes/netflix/Hero.tsx b/frontend-react/src/themes/netflix/Hero.tsx
new file mode 100644
index 0000000..c56b4d2
--- /dev/null
+++ b/frontend-react/src/themes/netflix/Hero.tsx
@@ -0,0 +1,87 @@
+import { useState, useEffect } from 'react';
+import { Play, Plus, Check } from 'lucide-react';
+import type { Movie } from '../../types';
+import { useMyList } from '../../hooks/useMyList';
+
+export const Hero = ({ movies }: { movies: Movie[] }) => {
+ const [index, setIndex] = useState(0);
+ const { addToList, removeFromList, isSaved } = useMyList();
+
+ useEffect(() => {
+ if (movies.length <= 1) return;
+ const interval = setInterval(() => {
+ setIndex((prev) => (prev + 1) % movies.length);
+ }, 8000);
+ return () => clearInterval(interval);
+ }, [movies]);
+
+ if (!movies || movies.length === 0) return null;
+
+ const movie = movies[index];
+ const saved = isSaved(movie.id);
+
+ const toggleList = () => {
+ if (saved) removeFromList(movie.id);
+ else addToList(movie);
+ };
+
+ return (
+
+
+

+
+
+
+
+
+
+
+ TOP 10 TODAY
+ #{index + 1} in Movies
+
+
+
+ {movie.title}
+
+
+ {movie.original_title && (
+
{movie.original_title}
+ )}
+
+
+
+
+ Play
+
+
+
+
+
+
+ {/* Indicators */}
+
+ {movies.map((_, i) => (
+
+
+ );
+};
diff --git a/frontend-react/src/themes/netflix/Layout.tsx b/frontend-react/src/themes/netflix/Layout.tsx
new file mode 100644
index 0000000..72cb0c5
--- /dev/null
+++ b/frontend-react/src/themes/netflix/Layout.tsx
@@ -0,0 +1,96 @@
+import { useState } from 'react';
+import type { ReactNode } from 'react';
+import { useLocation, Link, useNavigate } from 'react-router-dom';
+import { Search } from 'lucide-react';
+import { NAV_ITEMS } from '../../constants';
+
+export const Layout = ({ children }: { children: ReactNode }) => {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const isActive = (path: string) => {
+ if (path === '/') return location.pathname === '/' && !location.search;
+ return location.pathname + location.search === path;
+ };
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (searchQuery.trim()) {
+ navigate(`/?q=${encodeURIComponent(searchQuery)}`);
+ // Optional: close search or keep it open
+ }
+ };
+
+ return (
+
+ {/* Sidebar Navigation */}
+
+
+ {/* Mobile Bottom Nav (Visible only on small screens) */}
+
+ {NAV_ITEMS.slice(0, 5).map((item) => (
+
+
+ {item.name}
+
+ ))}
+
+
+ {/* Main Content Area */}
+
+ {children}
+
+
+ );
+};
diff --git a/frontend-react/src/themes/netflix/MovieGrid.tsx b/frontend-react/src/themes/netflix/MovieGrid.tsx
new file mode 100644
index 0000000..3888de6
--- /dev/null
+++ b/frontend-react/src/themes/netflix/MovieGrid.tsx
@@ -0,0 +1,28 @@
+import type { Movie } from '../../types';
+import { Card } from './Card';
+
+export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => {
+ if (loading) {
+ return (
+
+ {title &&
{title}
}
+
+ {[...Array(12)].map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+ {title &&
{title} >
}
+
+ {movies.map((movie) => (
+
+ ))}
+
+
+ );
+};
diff --git a/frontend-react/src/themes/netflix/NetflixHome.tsx b/frontend-react/src/themes/netflix/NetflixHome.tsx
new file mode 100644
index 0000000..5bf03f2
--- /dev/null
+++ b/frontend-react/src/themes/netflix/NetflixHome.tsx
@@ -0,0 +1,14 @@
+import { Layout } from './Layout';
+import { HomeContent } from '../../components/HomeContent';
+import { SettingsPanel } from '../../components/SettingsPanel';
+
+export const NetflixHome = () => {
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/frontend-react/src/themes/netflix/WatchPage.tsx b/frontend-react/src/themes/netflix/WatchPage.tsx
new file mode 100644
index 0000000..bb6a216
--- /dev/null
+++ b/frontend-react/src/themes/netflix/WatchPage.tsx
@@ -0,0 +1,220 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ArrowLeft, Play, ChevronDown, ChevronUp } from 'lucide-react';
+import { Layout } from './Layout';
+import { useWatchMovie } from '../../hooks/useWatchMovie';
+import MovieRow from '../../components/MovieRow';
+
+export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => {
+ const navigate = useNavigate();
+ const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode);
+ const [expanded, setExpanded] = useState(false);
+
+ if (!movie) return Loading...
;
+
+ const episodes = movie.episodes || [];
+ const visibleEpisodes = expanded ? episodes : episodes.slice(0, 20);
+
+ return (
+
+
+ {/* Back Button Overlay */}
+
+
+
+ {/* Sidebar / Metadata Panel */}
+
+
+ {/* Movie Header */}
+
+
{movie.title}
+
+ 98% Match
+ {movie.year || '2024'}
+ HD
+ {movie.original_title}
+
+
+
+
+ {movie.description}
+
+
+ {/* Episodes Grid */}
+ {episodes.length > 0 && (
+
+
+
Episodes
+
+ {episodes.length} Episodes
+
+
+
+
+ {visibleEpisodes.map((ep) => (
+
+ ))}
+
+
+ {episodes.length > 20 && (
+
+ )}
+
+ )}
+
+ {/* Related Content Section */}
+
+ {/* More Like This */}
+
+
More Like This
+
+
+
+ {/* Trending */}
+
+
New Releases
+
+
+
+ {/* Top Movies */}
+
+
Top Movies
+
+
+
+ {/* Top Series */}
+
+
Top Series
+
+
+
+ {/* Animation */}
+
+
Animation
+
+
+
+ {/* TV Shows */}
+
+
TV Shows
+
+
+
+
+
+
+ {/* Main Content Area (Player) */}
+
+ {loading && (
+
+ )}
+ {(() => {
+ const activeEpisode = movie.episodes?.find(e => e.number === currentEpisode);
+ if (!activeEpisode?.url) {
+ return (
+
+
+
Coming Soon
+
+ We're busy uploading the best quality version of this movie.
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ })()}
+
+
+
+
+ );
+};
diff --git a/frontend-react/src/themes/netflix/index.ts b/frontend-react/src/themes/netflix/index.ts
new file mode 100644
index 0000000..835138f
--- /dev/null
+++ b/frontend-react/src/themes/netflix/index.ts
@@ -0,0 +1,25 @@
+import type { Theme } from '../../types/Theme';
+import { Layout } from './Layout';
+import { Hero } from '../../components/Hero';
+import { MovieGrid } from './MovieGrid';
+import { Card } from './Card';
+import { WatchPage } from './WatchPage';
+import { NetflixHome } from './NetflixHome'; // Added
+
+export const netflixTheme: Theme = {
+ name: 'netflix',
+ label: 'Netflix',
+ colors: {
+ background: '#141414',
+ primary: '#E50914',
+ text: '#FFFFFF',
+ },
+ components: {
+ Layout,
+ Hero,
+ MovieGrid,
+ Card,
+ WatchPage,
+ Home: NetflixHome, // Added as Home
+ },
+};
diff --git a/frontend-react/src/types/Theme.ts b/frontend-react/src/types/Theme.ts
new file mode 100644
index 0000000..1b03a24
--- /dev/null
+++ b/frontend-react/src/types/Theme.ts
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+import type { Movie } from './index';
+
+export interface ThemeComponents {
+ Layout: React.ComponentType<{ children: ReactNode }>;
+ Hero: React.ComponentType<{ movies: Movie[] }>;
+ MovieGrid: React.ComponentType<{ movies: Movie[], loading?: boolean, title?: string }>;
+ Card: React.ComponentType<{ movie: Movie }>;
+ WatchPage: React.ComponentType<{ slug: string, episode: string }>;
+ Home: React.ComponentType; // Refactored to be self-contained
+}
+
+export type ThemeName = 'netflix' | 'apple' | 'default';
+
+export interface Theme {
+ name: ThemeName;
+ label: string;
+ colors: {
+ background: string;
+ primary: string;
+ text: string;
+ };
+ components: ThemeComponents;
+}
diff --git a/frontend-react/src/types/index.ts b/frontend-react/src/types/index.ts
new file mode 100644
index 0000000..a0c8908
--- /dev/null
+++ b/frontend-react/src/types/index.ts
@@ -0,0 +1,42 @@
+export interface Movie {
+ id: string;
+ title: string;
+ original_title?: string;
+ slug: string;
+ thumbnail: string;
+ backdrop?: string;
+ quality?: string;
+ year?: number;
+ category: string;
+ time?: string;
+ lang?: string;
+ director?: string;
+ cast?: string[];
+}
+
+export interface MovieDetail extends Movie {
+ description: string;
+ rating?: string;
+ duration?: number;
+ genre?: string;
+ director?: string;
+ country?: string;
+ cast?: string[];
+ episodes?: Episode[];
+}
+
+export interface Episode {
+ number: number;
+ title: string;
+ url: string;
+}
+
+export interface VideoSource {
+ stream_url: string;
+ resolution: string;
+ format_id: string;
+}
+export interface Category {
+ name: string;
+ slug: string;
+}
diff --git a/frontend-react/tailwind.config.js b/frontend-react/tailwind.config.js
new file mode 100644
index 0000000..ceb0351
--- /dev/null
+++ b/frontend-react/tailwind.config.js
@@ -0,0 +1,11 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/frontend-react/tsconfig.app.json b/frontend-react/tsconfig.app.json
new file mode 100644
index 0000000..a9b5a59
--- /dev/null
+++ b/frontend-react/tsconfig.app.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend-react/tsconfig.json b/frontend-react/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/frontend-react/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/frontend-react/tsconfig.node.json b/frontend-react/tsconfig.node.json
new file mode 100644
index 0000000..8a67f62
--- /dev/null
+++ b/frontend-react/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend-react/vite.config.ts b/frontend-react/vite.config.ts
new file mode 100644
index 0000000..5f3e7ff
--- /dev/null
+++ b/frontend-react/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ secure: false,
+ },
+ },
+ },
+})
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
deleted file mode 100755
index d091b30..0000000
--- a/frontend/Dockerfile
+++ /dev/null
@@ -1,18 +0,0 @@
-FROM node:20-alpine
-
-WORKDIR /app
-
-# Copy package files
-COPY package*.json ./
-
-# Install dependencies
-RUN npm install
-
-# Copy source code
-COPY . .
-
-# Expose port
-EXPOSE 3000
-
-# Run development server
-CMD ["npm", "run", "dev", "--", "--host"]
diff --git a/frontend/android/.gitignore b/frontend/android/.gitignore
deleted file mode 100755
index 48354a3..0000000
--- a/frontend/android/.gitignore
+++ /dev/null
@@ -1,101 +0,0 @@
-# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
-
-# Built application files
-*.apk
-*.aar
-*.ap_
-*.aab
-
-# Files for the ART/Dalvik VM
-*.dex
-
-# Java class files
-*.class
-
-# Generated files
-bin/
-gen/
-out/
-# Uncomment the following line in case you need and you don't have the release build type files in your app
-# release/
-
-# Gradle files
-.gradle/
-build/
-
-# Local configuration file (sdk path, etc)
-local.properties
-
-# Proguard folder generated by Eclipse
-proguard/
-
-# Log Files
-*.log
-
-# Android Studio Navigation editor temp files
-.navigation/
-
-# Android Studio captures folder
-captures/
-
-# IntelliJ
-*.iml
-.idea/workspace.xml
-.idea/tasks.xml
-.idea/gradle.xml
-.idea/assetWizardSettings.xml
-.idea/dictionaries
-.idea/libraries
-# Android Studio 3 in .gitignore file.
-.idea/caches
-.idea/modules.xml
-# Comment next line if keeping position of elements in Navigation Editor is relevant for you
-.idea/navEditor.xml
-
-# Keystore files
-# Uncomment the following lines if you do not want to check your keystore files in.
-#*.jks
-#*.keystore
-
-# External native build folder generated in Android Studio 2.2 and later
-.externalNativeBuild
-.cxx/
-
-# Google Services (e.g. APIs or Firebase)
-# google-services.json
-
-# Freeline
-freeline.py
-freeline/
-freeline_project_description.json
-
-# fastlane
-fastlane/report.xml
-fastlane/Preview.html
-fastlane/screenshots
-fastlane/test_output
-fastlane/readme.md
-
-# Version control
-vcs.xml
-
-# lint
-lint/intermediates/
-lint/generated/
-lint/outputs/
-lint/tmp/
-# lint/reports/
-
-# Android Profiling
-*.hprof
-
-# Cordova plugins for Capacitor
-capacitor-cordova-android-plugins
-
-# Copied web assets
-app/src/main/assets/public
-
-# Generated Config files
-app/src/main/assets/capacitor.config.json
-app/src/main/assets/capacitor.plugins.json
-app/src/main/res/xml/config.xml
diff --git a/frontend/android/app/.gitignore b/frontend/android/app/.gitignore
deleted file mode 100755
index 043df80..0000000
--- a/frontend/android/app/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-/build/*
-!/build/.npmkeep
diff --git a/frontend/android/app/build.gradle b/frontend/android/app/build.gradle
deleted file mode 100755
index e8cc543..0000000
--- a/frontend/android/app/build.gradle
+++ /dev/null
@@ -1,54 +0,0 @@
-apply plugin: 'com.android.application'
-
-android {
- namespace "com.streamflix.app"
- compileSdk rootProject.ext.compileSdkVersion
- defaultConfig {
- applicationId "com.streamflix.app"
- minSdkVersion rootProject.ext.minSdkVersion
- targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 11
- versionName "1.3.2"
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- aaptOptions {
- // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
- // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
- ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
- }
- }
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
- }
- }
-}
-
-repositories {
- flatDir{
- dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
- }
-}
-
-dependencies {
- implementation fileTree(include: ['*.jar'], dir: 'libs')
- implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
- implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
- implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
- implementation project(':capacitor-android')
- testImplementation "junit:junit:$junitVersion"
- androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
- androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
- implementation project(':capacitor-cordova-android-plugins')
-}
-
-apply from: 'capacitor.build.gradle'
-
-try {
- def servicesJSON = file('google-services.json')
- if (servicesJSON.text) {
- apply plugin: 'com.google.gms.google-services'
- }
-} catch(Exception e) {
- logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
-}
diff --git a/frontend/android/app/capacitor.build.gradle b/frontend/android/app/capacitor.build.gradle
deleted file mode 100755
index bf41ad6..0000000
--- a/frontend/android/app/capacitor.build.gradle
+++ /dev/null
@@ -1,20 +0,0 @@
-// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
-
-android {
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_17
- targetCompatibility JavaVersion.VERSION_17
- }
-}
-
-apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
-dependencies {
- implementation project(':capacitor-haptics')
- implementation project(':capacitor-status-bar')
-
-}
-
-
-if (hasProperty('postBuildExtras')) {
- postBuildExtras()
-}
diff --git a/frontend/android/app/proguard-rules.pro b/frontend/android/app/proguard-rules.pro
deleted file mode 100755
index f1b4245..0000000
--- a/frontend/android/app/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
diff --git a/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
deleted file mode 100755
index f2c2217..0000000
--- a/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.getcapacitor.myapp;
-
-import static org.junit.Assert.*;
-
-import android.content.Context;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.platform.app.InstrumentationRegistry;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * @see Testing documentation
- */
-@RunWith(AndroidJUnit4.class)
-public class ExampleInstrumentedTest {
-
- @Test
- public void useAppContext() throws Exception {
- // Context of the app under test.
- Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
-
- assertEquals("com.getcapacitor.app", appContext.getPackageName());
- }
-}
diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml
deleted file mode 100755
index c0401bf..0000000
--- a/frontend/android/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/android/app/src/main/java/com/streamflix/app/MainActivity.java b/frontend/android/app/src/main/java/com/streamflix/app/MainActivity.java
deleted file mode 100755
index 10a0087..0000000
--- a/frontend/android/app/src/main/java/com/streamflix/app/MainActivity.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.streamflix.app;
-
-import android.os.Bundle;
-import android.view.WindowManager;
-import android.os.PowerManager;
-import android.content.Context;
-import com.getcapacitor.BridgeActivity;
-
-public class MainActivity extends BridgeActivity {
- private PowerManager.WakeLock wakeLock;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- // Prevent screen from turning off while app is in foreground
- getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
-
- // Initialize CPU wake lock to keep processor active when screen is manually
- // turned off
- PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
- if (powerManager != null) {
- wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "StreamFlix::PlaybackWakeLock");
- wakeLock.acquire();
- }
- }
-
- @Override
- public void onDestroy() {
- if (wakeLock != null && wakeLock.isHeld()) {
- wakeLock.release();
- }
- super.onDestroy();
- }
-}
diff --git a/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png
deleted file mode 100755
index e31573b..0000000
Binary files a/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png
deleted file mode 100755
index f7a6492..0000000
Binary files a/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png
deleted file mode 100755
index 8077255..0000000
Binary files a/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png
deleted file mode 100755
index 14c6c8f..0000000
Binary files a/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
deleted file mode 100755
index 244ca25..0000000
Binary files a/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png
deleted file mode 100755
index 74faaa5..0000000
Binary files a/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png
deleted file mode 100755
index e944f4a..0000000
Binary files a/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png
deleted file mode 100755
index 564a82f..0000000
Binary files a/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png
deleted file mode 100755
index bfabe68..0000000
Binary files a/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
deleted file mode 100755
index 6929071..0000000
Binary files a/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
deleted file mode 100755
index c7bd21d..0000000
--- a/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml
deleted file mode 100755
index d5fccc5..0000000
--- a/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/android/app/src/main/res/drawable/splash.png b/frontend/android/app/src/main/res/drawable/splash.png
deleted file mode 100755
index f7a6492..0000000
Binary files a/frontend/android/app/src/main/res/drawable/splash.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/layout/activity_main.xml b/frontend/android/app/src/main/res/layout/activity_main.xml
deleted file mode 100755
index b5ad138..0000000
--- a/frontend/android/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100755
index 036d09b..0000000
--- a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
deleted file mode 100755
index 036d09b..0000000
--- a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100755
index 984033f..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
deleted file mode 100755
index d73809b..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100755
index 984033f..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100755
index a070330..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
deleted file mode 100755
index 3f406d4..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100755
index a070330..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100755
index a034f78..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
deleted file mode 100755
index 979eec2..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100755
index a034f78..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100755
index f6088b3..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
deleted file mode 100755
index 4f3f46a..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100755
index f6088b3..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100755
index cce41b7..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
deleted file mode 100755
index 676267f..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100755
index cce41b7..0000000
Binary files a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/frontend/android/app/src/main/res/values/ic_launcher_background.xml b/frontend/android/app/src/main/res/values/ic_launcher_background.xml
deleted file mode 100755
index c5d5899..0000000
--- a/frontend/android/app/src/main/res/values/ic_launcher_background.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- #FFFFFF
-
\ No newline at end of file
diff --git a/frontend/android/app/src/main/res/values/strings.xml b/frontend/android/app/src/main/res/values/strings.xml
deleted file mode 100755
index ea2d30f..0000000
--- a/frontend/android/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
- StreamFlix
- StreamFlix
- com.streamflix.app
- com.streamflix.app
-
diff --git a/frontend/android/app/src/main/res/values/styles.xml b/frontend/android/app/src/main/res/values/styles.xml
deleted file mode 100755
index be874e5..0000000
--- a/frontend/android/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/android/app/src/main/res/xml/file_paths.xml b/frontend/android/app/src/main/res/xml/file_paths.xml
deleted file mode 100755
index bd0c4d8..0000000
--- a/frontend/android/app/src/main/res/xml/file_paths.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java
deleted file mode 100755
index 0297327..0000000
--- a/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.getcapacitor.myapp;
-
-import static org.junit.Assert.*;
-
-import org.junit.Test;
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * @see Testing documentation
- */
-public class ExampleUnitTest {
-
- @Test
- public void addition_isCorrect() throws Exception {
- assertEquals(4, 2 + 2);
- }
-}
diff --git a/frontend/android/build.gradle b/frontend/android/build.gradle
deleted file mode 100755
index 849eca9..0000000
--- a/frontend/android/build.gradle
+++ /dev/null
@@ -1,41 +0,0 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-
-buildscript {
-
- repositories {
- google()
- mavenCentral()
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:8.7.3'
- classpath 'com.google.gms:google-services:4.4.0'
-
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
-}
-
-apply from: "variables.gradle"
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
-
-subprojects {
- configurations.all {
- resolutionStrategy {
- eachDependency { details ->
- if (details.requested.group == 'org.jetbrains.kotlin') {
- details.useVersion '1.8.22'
- }
- }
- }
- }
-}
-
-task clean(type: Delete) {
- delete rootProject.buildDir
-}
diff --git a/frontend/android/capacitor.settings.gradle b/frontend/android/capacitor.settings.gradle
deleted file mode 100755
index 848d52f..0000000
--- a/frontend/android/capacitor.settings.gradle
+++ /dev/null
@@ -1,9 +0,0 @@
-// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
-include ':capacitor-android'
-project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
-
-include ':capacitor-haptics'
-project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
-
-include ':capacitor-status-bar'
-project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
diff --git a/frontend/android/gradle.properties b/frontend/android/gradle.properties
deleted file mode 100755
index fb841dc..0000000
--- a/frontend/android/gradle.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
-# org.gradle.java.home=/Users/khoa.vo/Downloads/Streamflow-main/frontend/android/.jdk21/Contents/Home
-android.useAndroidX=true
-android.enableJetifier=true
diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.jar b/frontend/android/gradle/wrapper/gradle-wrapper.jar
deleted file mode 100755
index 033e24c..0000000
Binary files a/frontend/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.properties b/frontend/android/gradle/wrapper/gradle-wrapper.properties
deleted file mode 100755
index 79eb9d0..0000000
--- a/frontend/android/gradle/wrapper/gradle-wrapper.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-distributionBase=GRADLE_USER_HOME
-distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
-networkTimeout=10000
-validateDistributionUrl=true
-zipStoreBase=GRADLE_USER_HOME
-zipStorePath=wrapper/dists
diff --git a/frontend/android/gradlew b/frontend/android/gradlew
deleted file mode 100755
index fcb6fca..0000000
--- a/frontend/android/gradlew
+++ /dev/null
@@ -1,248 +0,0 @@
-#!/bin/sh
-
-#
-# Copyright © 2015-2021 the original authors.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-
-##############################################################################
-#
-# Gradle start up script for POSIX generated by Gradle.
-#
-# Important for running:
-#
-# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
-# noncompliant, but you have some other compliant shell such as ksh or
-# bash, then to run this script, type that shell name before the whole
-# command line, like:
-#
-# ksh Gradle
-#
-# Busybox and similar reduced shells will NOT work, because this script
-# requires all of these POSIX shell features:
-# * functions;
-# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
-# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
-# * compound commands having a testable exit status, especially «case»;
-# * various built-in commands including «command», «set», and «ulimit».
-#
-# Important for patching:
-#
-# (2) This script targets any POSIX shell, so it avoids extensions provided
-# by Bash, Ksh, etc; in particular arrays are avoided.
-#
-# The "traditional" practice of packing multiple parameters into a
-# space-separated string is a well documented source of bugs and security
-# problems, so this is (mostly) avoided, by progressively accumulating
-# options in "$@", and eventually passing that to Java.
-#
-# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
-# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
-# see the in-line comments for details.
-#
-# There are tweaks for specific operating systems such as AIX, CygWin,
-# Darwin, MinGW, and NonStop.
-#
-# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
-# within the Gradle project.
-#
-# You can find Gradle at https://github.com/gradle/gradle/.
-#
-##############################################################################
-
-# Attempt to set APP_HOME
-
-# Resolve links: $0 may be a link
-app_path=$0
-
-# Need this for daisy-chained symlinks.
-while
- APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
- [ -h "$app_path" ]
-do
- ls=$( ls -ld "$app_path" )
- link=${ls#*' -> '}
- case $link in #(
- /*) app_path=$link ;; #(
- *) app_path=$APP_HOME$link ;;
- esac
-done
-
-# This is normally unused
-# shellcheck disable=SC2034
-APP_BASE_NAME=${0##*/}
-APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
-
-# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD=maximum
-
-warn () {
- echo "$*"
-} >&2
-
-die () {
- echo
- echo "$*"
- echo
- exit 1
-} >&2
-
-# OS specific support (must be 'true' or 'false').
-cygwin=false
-msys=false
-darwin=false
-nonstop=false
-case "$( uname )" in #(
- CYGWIN* ) cygwin=true ;; #(
- Darwin* ) darwin=true ;; #(
- MSYS* | MINGW* ) msys=true ;; #(
- NONSTOP* ) nonstop=true ;;
-esac
-
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
-
-
-# Determine the Java command to use to start the JVM.
-if [ -n "$JAVA_HOME" ] ; then
- if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
- # IBM's JDK on AIX uses strange locations for the executables
- JAVACMD=$JAVA_HOME/jre/sh/java
- else
- JAVACMD=$JAVA_HOME/bin/java
- fi
- if [ ! -x "$JAVACMD" ] ; then
- die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
- fi
-else
- JAVACMD=java
- if ! command -v java >/dev/null 2>&1
- then
- die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
- fi
-fi
-
-# Increase the maximum file descriptors if we can.
-if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
- case $MAX_FD in #(
- max*)
- # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC3045
- MAX_FD=$( ulimit -H -n ) ||
- warn "Could not query maximum file descriptor limit"
- esac
- case $MAX_FD in #(
- '' | soft) :;; #(
- *)
- # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC3045
- ulimit -n "$MAX_FD" ||
- warn "Could not set maximum file descriptor limit to $MAX_FD"
- esac
-fi
-
-# Collect all arguments for the java command, stacking in reverse order:
-# * args from the command line
-# * the main class name
-# * -classpath
-# * -D...appname settings
-# * --module-path (only if needed)
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
-
-# For Cygwin or MSYS, switch paths to Windows format before running java
-if "$cygwin" || "$msys" ; then
- APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
- CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
-
- JAVACMD=$( cygpath --unix "$JAVACMD" )
-
- # Now convert the arguments - kludge to limit ourselves to /bin/sh
- for arg do
- if
- case $arg in #(
- -*) false ;; # don't mess with options #(
- /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
- [ -e "$t" ] ;; #(
- *) false ;;
- esac
- then
- arg=$( cygpath --path --ignore --mixed "$arg" )
- fi
- # Roll the args list around exactly as many times as the number of
- # args, so each arg winds up back in the position where it started, but
- # possibly modified.
- #
- # NB: a `for` loop captures its iteration list before it begins, so
- # changing the positional parameters here affects neither the number of
- # iterations, nor the values presented in `arg`.
- shift # remove old arg
- set -- "$@" "$arg" # push replacement arg
- done
-fi
-
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
-
-# Collect all arguments for the java command;
-# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
-# shell script including quotes and variable substitutions, so put them in
-# double quotes to make sure that they get re-expanded; and
-# * put everything else in single quotes, so that it's not re-expanded.
-
-set -- \
- "-Dorg.gradle.appname=$APP_BASE_NAME" \
- -classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
- "$@"
-
-# Stop when "xargs" is not available.
-if ! command -v xargs >/dev/null 2>&1
-then
- die "xargs is not available"
-fi
-
-# Use "xargs" to parse quoted args.
-#
-# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
-#
-# In Bash we could simply go:
-#
-# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
-# set -- "${ARGS[@]}" "$@"
-#
-# but POSIX shell has neither arrays nor command substitution, so instead we
-# post-process each arg (as a line of input to sed) to backslash-escape any
-# character that might be a shell metacharacter, then use eval to reverse
-# that process (while maintaining the separation between arguments), and wrap
-# the whole thing up as a single "set" statement.
-#
-# This will of course break if any of these variables contains a newline or
-# an unmatched quote.
-#
-
-eval "set -- $(
- printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
- xargs -n1 |
- sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
- tr '\n' ' '
- )" '"$@"'
-
-exec "$JAVACMD" "$@"
diff --git a/frontend/android/gradlew.bat b/frontend/android/gradlew.bat
deleted file mode 100755
index 6689b85..0000000
--- a/frontend/android/gradlew.bat
+++ /dev/null
@@ -1,92 +0,0 @@
-@rem
-@rem Copyright 2015 the original author or authors.
-@rem
-@rem Licensed under the Apache License, Version 2.0 (the "License");
-@rem you may not use this file except in compliance with the License.
-@rem You may obtain a copy of the License at
-@rem
-@rem https://www.apache.org/licenses/LICENSE-2.0
-@rem
-@rem Unless required by applicable law or agreed to in writing, software
-@rem distributed under the License is distributed on an "AS IS" BASIS,
-@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-@rem See the License for the specific language governing permissions and
-@rem limitations under the License.
-@rem
-
-@if "%DEBUG%"=="" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%"=="" set DIRNAME=.
-@rem This is normally unused
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Resolve any "." and ".." in APP_HOME to make it shorter.
-for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if %ERRORLEVEL% equ 0 goto execute
-
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto execute
-
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
-
-:end
-@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-set EXIT_CODE=%ERRORLEVEL%
-if %EXIT_CODE% equ 0 set EXIT_CODE=1
-if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
-exit /b %EXIT_CODE%
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
diff --git a/frontend/android/settings.gradle b/frontend/android/settings.gradle
deleted file mode 100755
index 3b4431d..0000000
--- a/frontend/android/settings.gradle
+++ /dev/null
@@ -1,5 +0,0 @@
-include ':app'
-include ':capacitor-cordova-android-plugins'
-project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
-
-apply from: 'capacitor.settings.gradle'
\ No newline at end of file
diff --git a/frontend/android/variables.gradle b/frontend/android/variables.gradle
deleted file mode 100755
index ee6a90a..0000000
--- a/frontend/android/variables.gradle
+++ /dev/null
@@ -1,16 +0,0 @@
-ext {
- minSdkVersion = 22
- compileSdkVersion = 35
- targetSdkVersion = 35
- androidxActivityVersion = '1.8.0'
- androidxAppCompatVersion = '1.6.1'
- androidxCoordinatorLayoutVersion = '1.2.0'
- androidxCoreVersion = '1.12.0'
- androidxFragmentVersion = '1.6.2'
- coreSplashScreenVersion = '1.0.1'
- androidxWebkitVersion = '1.9.0'
- junitVersion = '4.13.2'
- androidxJunitVersion = '1.1.5'
- androidxEspressoCoreVersion = '3.5.1'
- cordovaAndroidVersion = '10.1.1'
-}
\ No newline at end of file
diff --git a/frontend/assets/apple-touch-icon.svg b/frontend/assets/apple-touch-icon.svg
deleted file mode 100755
index 422f8e4..0000000
--- a/frontend/assets/apple-touch-icon.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-
diff --git a/frontend/assets/favicon.svg b/frontend/assets/favicon.svg
deleted file mode 100755
index 24710e7..0000000
--- a/frontend/assets/favicon.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
diff --git a/frontend/assets/icon.svg b/frontend/assets/icon.svg
deleted file mode 100755
index cede46d..0000000
--- a/frontend/assets/icon.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
diff --git a/frontend/assets/logo.svg b/frontend/assets/logo.svg
deleted file mode 100755
index 5d3d943..0000000
--- a/frontend/assets/logo.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
diff --git a/frontend/capacitor.config.json b/frontend/capacitor.config.json
deleted file mode 100755
index ed9efb0..0000000
--- a/frontend/capacitor.config.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "appId": "com.streamflix.app",
- "appName": "StreamFlix",
- "webDir": "dist",
- "server": {
- "url": "https://nf.khoavo.myds.me",
- "cleartext": true
- }
-}
\ No newline at end of file
diff --git a/frontend/download.html b/frontend/download.html
deleted file mode 100755
index 28e6d98..0000000
--- a/frontend/download.html
+++ /dev/null
@@ -1,285 +0,0 @@
-
-
-
-
-
-
- StreamFlix - Download App
-
-
-
-
-
-
-
- ↠Back to StreamFlix
-
-
-

-
-
Download StreamFlix
-
Experience cinema-quality streaming on all your devices. Ad-free, high performance, and
- built for privacy.
-
-
-
-
- StreamFlix • Private Access Only
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/history.html b/frontend/history.html
deleted file mode 100755
index 193ba23..0000000
--- a/frontend/history.html
+++ /dev/null
@@ -1,232 +0,0 @@
-
-
-
-
-
-
- Watch History - KV-Stream
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
deleted file mode 100755
index a51c43c..0000000
--- a/frontend/index.html
+++ /dev/null
@@ -1,472 +0,0 @@
-
-
-
-
-
-
- StreamFlix - Homepage
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

-
-
Initializing StreamFlix...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- local_fire_department
-
-
-
-
-
-
- Loading...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- download
- Get App
-
-
-
-
-
-
-
-
- search
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/info.html b/frontend/info.html
deleted file mode 100755
index 0162faf..0000000
--- a/frontend/info.html
+++ /dev/null
@@ -1,305 +0,0 @@
-
-
-
-
-
-
- Movie Details - KV-Stream
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![Poster]()
-
-
-
-
-
Loading...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/js/capacitor-mock.js b/frontend/js/capacitor-mock.js
deleted file mode 100755
index 818c00a..0000000
--- a/frontend/js/capacitor-mock.js
+++ /dev/null
@@ -1,25 +0,0 @@
-// 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'
-};
diff --git a/frontend/package.json b/frontend/package.json
deleted file mode 100755
index 3340bae..0000000
--- a/frontend/package.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "name": "streamflow-frontend",
- "version": "1.0.6",
- "description": "StreamFlow - Ad-free video streaming",
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview"
- },
- "dependencies": {
- "@capacitor/android": "^8.0.0",
- "@capacitor/haptics": "^8.0.0",
- "@capacitor/status-bar": "^8.0.0",
- "artplayer": "^5.1.1"
- },
- "devDependencies": {
- "@capacitor/cli": "^6.2.1",
- "@capacitor/core": "^8.0.0",
- "vite": "^5.0.8"
- }
-}
\ No newline at end of file
diff --git a/frontend/public/history.html b/frontend/public/history.html
deleted file mode 100755
index 193ba23..0000000
--- a/frontend/public/history.html
+++ /dev/null
@@ -1,232 +0,0 @@
-
-
-
-
-
-
- Watch History - KV-Stream
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/public/info.html b/frontend/public/info.html
deleted file mode 100755
index 0162faf..0000000
--- a/frontend/public/info.html
+++ /dev/null
@@ -1,305 +0,0 @@
-
-
-
-
-
-
- Movie Details - KV-Stream
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![Poster]()
-
-
-
-
-
Loading...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/public/js/capacitor-mock.js b/frontend/public/js/capacitor-mock.js
deleted file mode 100755
index 818c00a..0000000
--- a/frontend/public/js/capacitor-mock.js
+++ /dev/null
@@ -1,25 +0,0 @@
-// 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'
-};
diff --git a/frontend/public/js/history-service.js b/frontend/public/js/history-service.js
deleted file mode 100755
index a7e0113..0000000
--- a/frontend/public/js/history-service.js
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * HistoryService - Manages watch history using localStorage
- * Allows users to save progress without logging in.
- */
-if (!window.HistoryService) {
- window.HistoryService = class HistoryService {
- constructor() {
- this.STORAGE_KEY = 'kv_watch_history';
- this.MAX_ITEMS = 100; // Limit history size
- }
-
- /**
- * Get all history items
- * @returns {Array} List of history items sorted by timestamp (newest first)
- */
- getHistory() {
- try {
- const history = localStorage.getItem(this.STORAGE_KEY);
- return history ? JSON.parse(history) : [];
- } catch (e) {
- console.error('Error reading history:', e);
- return [];
- }
- }
-
- /**
- * Add or update a movie/episode in history
- * @param {Object} movie - Movie object
- * @param {Object} progress - Progress info (optional)
- */
- addToHistory(movie, progress = {}) {
- const history = this.getHistory();
-
- // Remove existing entry for this item if it exists
- // Identify by slug
- const existingIndex = history.findIndex(item => item.slug === movie.slug);
-
- if (existingIndex !== -1) {
- history.splice(existingIndex, 1);
- }
-
- // Create new entry
- const entry = {
- id: movie.id || movie.slug,
- slug: movie.slug,
- title: movie.title,
- thumbnail: movie.thumbnail,
- backdrop: movie.backdrop,
- description: movie.description,
- timestamp: Date.now(),
- progress: {
- currentTime: progress.currentTime || 0,
- duration: progress.duration || 0,
- percentage: progress.percentage || 0,
- episode: progress.episode || 1
- },
- ...movie // Store other metadata
- };
-
- // Add to front
- history.unshift(entry);
-
- // Trim size
- if (history.length > this.MAX_ITEMS) {
- history.pop();
- }
-
- this.saveHistory(history);
- }
-
- // --- Favorites (My List) Methods ---
-
- getFavorites() {
- try {
- const list = localStorage.getItem('myList');
- return list ? JSON.parse(list) : [];
- } catch (e) { return []; }
- }
-
- toggleFavorite(movie) {
- let list = this.getFavorites();
- const exists = list.some(item => item.slug === movie.slug);
-
- if (exists) {
- list = list.filter(item => item.slug !== movie.slug);
- } else {
- list.push({
- id: movie.id || movie.slug,
- slug: movie.slug,
- title: movie.title,
- thumbnail: movie.thumbnail,
- addedAt: Date.now()
- });
- }
-
- localStorage.setItem('myList', JSON.stringify(list));
- return !exists; // Return true if added, false if removed
- }
-
- isFavorite(slug) {
- return this.getFavorites().some(item => item.slug === slug);
- }
-
- /**
- * Remove an item from history
- * @param {String} slug
- */
- removeFromHistory(slug) {
- let history = this.getHistory();
- history = history.filter(item => item.slug !== slug);
- this.saveHistory(history);
- }
-
- /**
- * Clear all history
- */
- clearHistory() {
- localStorage.removeItem(this.STORAGE_KEY);
- }
-
- saveHistory(history) {
- try {
- localStorage.setItem(this.STORAGE_KEY, JSON.stringify(history));
- // Dispatch event for UI updates
- window.dispatchEvent(new CustomEvent('history-updated', { detail: history }));
- } catch (e) {
- console.error('Error saving history:', e);
- }
- }
-
- /**
- * Check if a movie is in history
- */
- isInHistory(slug) {
- return this.getHistory().some(item => item.slug === slug);
- }
- }
-
- // Export singleton
- window.historyService = new window.HistoryService();
-}
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
deleted file mode 100755
index 62464c7..0000000
--- a/frontend/public/manifest.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "KV-Stream",
- "short_name": "KV-Stream",
- "description": "Premium Movie Streaming with Liquid Glass Design",
- "start_url": "/",
- "display": "standalone",
- "background_color": "#000000",
- "theme_color": "#000000",
- "icons": [
- {
- "src": "/icons/icon-512.png",
- "sizes": "512x512",
- "type": "image/png",
- "purpose": "any maskable"
- }
- ]
-}
\ No newline at end of file
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
deleted file mode 100755
index e3a8e52..0000000
--- a/frontend/public/sw.js
+++ /dev/null
@@ -1,20 +0,0 @@
-const CACHE_NAME = 'kv-stream-v1';
-const ASSETS = [
- '/',
- '/index.html',
- '/watch.html',
- '/styles/index.css',
- '/icons/icon-512.png'
-];
-
-self.addEventListener('install', (event) => {
- event.waitUntil(
- caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
- );
-});
-
-self.addEventListener('fetch', (event) => {
- event.respondWith(
- caches.match(event.request).then((response) => response || fetch(event.request))
- );
-});
diff --git a/frontend/scripts/api.js b/frontend/scripts/api.js
deleted file mode 100755
index e81ef69..0000000
--- a/frontend/scripts/api.js
+++ /dev/null
@@ -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 = [115, 102, 95, 116, 118, 95, 115, 101, 99, 117, 114, 101, 95, 57, 115, 56, 100, 55, 102, 54, 103, 53, 104, 52, 106, 51, 107, 50, 108, 49];
-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
- `;
- 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;
-}
diff --git a/frontend/scripts/components/Toast.js b/frontend/scripts/components/Toast.js
deleted file mode 100755
index c4cf31e..0000000
--- a/frontend/scripts/components/Toast.js
+++ /dev/null
@@ -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 = `
-
- ${escapeHtml(message)}
- `;
-
- 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 '';
- case 'error':
- return '';
- default:
- return '';
- }
-}
-
-/**
- * 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;
-}
diff --git a/frontend/scripts/components/VideoCard.js b/frontend/scripts/components/VideoCard.js
deleted file mode 100755
index cbc7ec0..0000000
--- a/frontend/scripts/components/VideoCard.js
+++ /dev/null
@@ -1,243 +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;
- 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)
- 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 = `
-
- ${rating.toFixed(1)}
-
- `;
- }
-
- // Build rating badge HTML (Rotten Tomatoes style)
- let tomatoBadgeHTML = '';
- if (rating > 0) {
- const tomatoIcon = isFresh ? 'ðŸ…' : '🥀';
- tomatoBadgeHTML = `
-
- ${tomatoIcon}
- ${ratingPercent}%
-
- `;
- }
-
- // 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 += `MỚI`;
- }
-
- // Type tag (SERIES / PHIM LẺ)
- if (movieType === 'trailer') {
- tagsHTML += `TRAILER`;
- } else if (movieType === 'series') {
- tagsHTML += `PHIM BỘ`;
- } else if (movieType === 'animation') {
- tagsHTML += `HOẠT HÌNH`;
- }
-
- card.innerHTML = `
-
-
-

-
-
-
- ${tagsHTML}
-
-
-
-
- ${tomatoBadgeHTML}
- ${numericRatingHTML}
- ${qualityBadge}
-
-
-
-
- ${year}
- ${episodeText ? `${episodeText}` : ''}
-
-
-
- ${video.progress && video.progress.percentage > 0 ? `
-
- ` : ''}
-
-
-
-
-
-
-
-
- ${escapeHtml(video.title)}
-
- `;
-
- // 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;
-}
diff --git a/frontend/scripts/components/VideoPlayer.js b/frontend/scripts/components/VideoPlayer.js
deleted file mode 100755
index e6f7c01..0000000
--- a/frontend/scripts/components/VideoPlayer.js
+++ /dev/null
@@ -1,310 +0,0 @@
-/**
- * StreamFlow - Video Player Component
- * ArtPlayer.js integration with custom skin
- * Includes Screen Wake Lock API to prevent screen sleep during playback
- */
-
-import Artplayer from 'artplayer';
-
-// Player instance reference
-let currentPlayer = null;
-
-// Wake lock instance for preventing screen sleep
-let wakeLock = null;
-
-/**
- * Request screen wake lock to prevent display from sleeping
- */
-async function requestWakeLock() {
- if ('wakeLock' in navigator) {
- try {
- wakeLock = await navigator.wakeLock.request('screen');
- console.log('Wake lock acquired');
-
- // Handle wake lock release (e.g., when tab loses visibility)
- wakeLock.addEventListener('release', () => {
- console.log('Wake lock released');
- });
- } catch (err) {
- console.log('Wake lock request failed:', err.message);
- }
- }
-}
-
-/**
- * Release screen wake lock
- */
-async function releaseWakeLock() {
- if (wakeLock !== null) {
- try {
- await wakeLock.release();
- wakeLock = null;
- console.log('Wake lock released');
- } catch (err) {
- console.log('Wake lock release failed:', err.message);
- }
- }
-}
-
-/**
- * Handle visibility change to re-acquire wake lock when tab becomes visible
- */
-function handleVisibilityChange() {
- if (document.visibilityState === 'visible' && currentPlayer && !currentPlayer.paused) {
- requestWakeLock();
- }
-}
-
-// Register visibility change listener
-document.addEventListener('visibilitychange', handleVisibilityChange);
-
-/**
- * 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: ``,
- state: ``
- },
- 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);
- });
-
- // Wake lock events - keep screen on during playback
- currentPlayer.on('play', () => {
- requestWakeLock();
- });
-
- currentPlayer.on('pause', () => {
- releaseWakeLock();
- });
-
- return currentPlayer;
-}
-
-/**
- * Destroy current player instance
- */
-export function destroyPlayer() {
- if (currentPlayer) {
- currentPlayer.destroy();
- currentPlayer = null;
- }
- // Release wake lock when player is destroyed
- releaseWakeLock();
-}
-
-/**
- * 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 = `
-
- `;
-
- if (onClick) {
- placeholder.addEventListener('click', onClick);
- }
-
- return placeholder;
-}
diff --git a/frontend/scripts/haptics.js b/frontend/scripts/haptics.js
deleted file mode 100755
index 666b72e..0000000
--- a/frontend/scripts/haptics.js
+++ /dev/null
@@ -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
- }
-};
diff --git a/frontend/scripts/info.js b/frontend/scripts/info.js
deleted file mode 100755
index a7bb94e..0000000
--- a/frontend/scripts/info.js
+++ /dev/null
@@ -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 = `${status}`;
- }
-
- 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 =>
- `${t}`
- ).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 => `
-
-
- ${v.title}
- ${v.year || ''}
-
- `).join('');
- } catch (e) {
- console.warn('Failed to load recs', e);
- }
-}
-
-init();
diff --git a/frontend/scripts/keyboard-nav.js b/frontend/scripts/keyboard-nav.js
deleted file mode 100755
index 3e8d4c7..0000000
--- a/frontend/scripts/keyboard-nav.js
+++ /dev/null
@@ -1,378 +0,0 @@
-/**
- * TV-Style Keyboard Navigation
- * Handles Arrow keys to navigate horizontally through sliders and vertically between rows.
- * Optimized for Android TV D-pad remote control navigation.
- */
-
-export class KeyboardNavigation {
- constructor() {
- this.currentFocus = null;
- this.isEnabled = false;
- this.isTVMode = this.detectTVMode();
- this.focusInitialized = false;
-
- // Selectors for focusable items (in priority order)
- this.selectors = [
- '.video-card',
- '.hero__btn',
- '.slider-btn',
- '#topSearchBtn',
- '.nav-item',
- '.nav-link',
- '.category-card',
- '.tab-btn',
- '.episode-row',
- '.recommendation-card',
- 'button:not([disabled])',
- 'a[href]'
- ];
- }
-
- /**
- * Detect if running on Android TV or similar leanback device
- * Uses multiple detection methods for reliability
- */
- detectTVMode() {
- const ua = navigator.userAgent.toLowerCase();
-
- // Check UA for known TV strings
- const tvPatterns = [
- 'tv', 'aftm', 'aftt', 'aft', 'shield', 'googletv',
- 'chromecast', 'firetv', 'bravia', 'philipstv', 'samsungtv',
- 'lgtv', 'webos', 'tizen', 'vizio', 'roku', 'appletv'
- ];
- const isAndroid = ua.includes('android');
- const hasTV = tvPatterns.some(p => ua.includes(p));
-
- // Fallback: No fine pointer (mouse) likely means D-pad/remote
- const noMouse = window.matchMedia && !window.matchMedia('(pointer: fine)').matches;
-
- // Fallback: Large screen without touch is likely TV
- const isBigScreen = window.innerWidth >= 1280 && window.innerHeight >= 720;
- const noTouch = !('ontouchstart' in window);
-
- const detected = (isAndroid && hasTV) || (isAndroid && noMouse) || (isBigScreen && noTouch && noMouse);
-
- if (detected) {
- console.log('[KeyboardNav] TV Mode detected');
- document.body.classList.add('tv-mode');
- }
-
- return detected;
- }
-
- init() {
- this.isEnabled = true;
- document.addEventListener('keydown', this.handleKey.bind(this));
-
- // Only add mouse handler on non-TV devices
- if (!this.isTVMode) {
- document.addEventListener('mousemove', this.handleMouseMove.bind(this));
- }
-
- // Add tabindex to all focusable elements for D-pad navigation
- this.ensureTabIndexes();
-
- // Auto-focus first card for TV mode (helps D-pad users start navigating)
- if (this.isTVMode) {
- this.waitForFocusableElement();
- }
-
- // Re-apply tabindex when DOM changes (e.g., new content loaded)
- this.observeDOM();
- }
-
- /**
- * Ensure all interactive elements have tabindex for focus
- */
- ensureTabIndexes() {
- const elements = document.querySelectorAll(this.selectors.join(','));
- elements.forEach(el => {
- if (!el.hasAttribute('tabindex')) {
- el.setAttribute('tabindex', '0');
- }
- });
- }
-
- /**
- * Observe DOM for new elements and add tabindex
- */
- observeDOM() {
- const observer = new MutationObserver((mutations) => {
- let needsUpdate = false;
- for (const mutation of mutations) {
- if (mutation.addedNodes.length > 0) {
- needsUpdate = true;
- break;
- }
- }
- if (needsUpdate) {
- // Debounce updates
- clearTimeout(this._tabindexTimeout);
- this._tabindexTimeout = setTimeout(() => {
- this.ensureTabIndexes();
- // Try to focus if not yet focused
- if (this.isTVMode && !this.focusInitialized) {
- this.focusFirstVisible();
- }
- }, 100);
- }
- });
-
- observer.observe(document.body, {
- childList: true,
- subtree: true
- });
- }
-
- /**
- * Wait for focusable elements to appear, then focus the first one
- */
- waitForFocusableElement() {
- const tryFocus = (attempt = 0) => {
- const candidates = document.querySelectorAll('.video-card');
- if (candidates.length > 0) {
- this.setFocus(candidates[0]);
- this.focusInitialized = true;
- console.log('[KeyboardNav] Initial focus set');
- } else if (attempt < 10) {
- // Retry with exponential backoff (100ms, 200ms, 400ms, ...)
- setTimeout(() => tryFocus(attempt + 1), 100 * Math.pow(2, attempt));
- }
- };
-
- // Initial delay to let page settle
- setTimeout(() => tryFocus(0), 300);
- }
-
- handleMouseMove() {
- // If mouse moves, likely user is using mouse.
- if (this.currentFocus) {
- this.currentFocus.blur();
- this.currentFocus.classList.remove('keyboard-focused');
- this.currentFocus = null;
- }
- }
-
- handleKey(e) {
- // Handle navigation keys
- 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' || e.key === ' ') {
- // Select/activate focused element
- if (this.currentFocus) {
- e.preventDefault();
- this.currentFocus.click();
- }
- } else if (e.key === 'Backspace' || e.key === 'Escape' || e.key === 'XF86Back') {
- // Back button handling for Android TV
- this.handleBack(e);
- }
- }
-
- /**
- * Handle back button for Android TV
- */
- handleBack(e) {
- // Check for open modals/overlays first
- const searchModal = document.getElementById('searchModal');
- const playerModal = document.getElementById('playerModal');
- const infoModal = document.querySelector('.info-modal.active, .info-modal:not(.hidden)');
- const videoPlayerContainer = document.getElementById('videoPlayerContainer');
-
- // Close modals in priority order
- if (searchModal?.classList.contains('active')) {
- e.preventDefault();
- searchModal.classList.remove('active');
- return;
- }
-
- if (infoModal) {
- e.preventDefault();
- infoModal.classList.add('hidden');
- infoModal.classList.remove('active');
- return;
- }
-
- if (videoPlayerContainer && !videoPlayerContainer.classList.contains('hidden')) {
- e.preventDefault();
- // Trigger close player - the page's own handler should catch this
- const closeBtn = document.getElementById('closePlayer') || document.getElementById('playerBackButton');
- if (closeBtn) closeBtn.click();
- return;
- }
-
- if (playerModal?.classList.contains('active')) {
- e.preventDefault();
- const closePlayerBtn = document.getElementById('closePlayer');
- if (closePlayerBtn) closePlayerBtn.click();
- return;
- }
-
- // If no modal is open, let default back behavior happen
- // (e.g., browser back or Capacitor's back handling)
- }
-
- focusFirstVisible() {
- // Find first video card in viewport
- const candidates = document.querySelectorAll('.video-card');
- if (candidates.length > 0) {
- this.setFocus(candidates[0]);
- this.focusInitialized = true;
- }
- }
-
- setFocus(el) {
- if (!el) return;
-
- if (this.currentFocus) {
- this.currentFocus.classList.remove('keyboard-focused');
- }
-
- 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'
- });
- }
-
- /**
- * Get the row container of an element
- */
- getRowContainer(el) {
- return el.closest('.video-row, .slider-row, .row-content, .grid, .episodes-grid, .recommendations-container');
- }
-
- /**
- * Get all focusable elements within a container (or document)
- */
- getFocusableInContainer(container) {
- const selector = this.selectors.slice(0, 10).join(','); // Primary interactive elements
- return container
- ? Array.from(container.querySelectorAll(selector))
- : Array.from(document.querySelectorAll(selector));
- }
-
- moveHorizontal(direction) {
- if (!this.currentFocus) return null;
-
- // Try to stay within the same row/container
- const row = this.getRowContainer(this.currentFocus);
-
- if (row) {
- // Get siblings in the same row
- const siblings = this.getFocusableInContainer(row);
- const currentIndex = siblings.indexOf(this.currentFocus);
-
- if (currentIndex !== -1) {
- const nextIndex = currentIndex + direction;
- if (nextIndex >= 0 && nextIndex < siblings.length) {
- return siblings[nextIndex];
- }
- }
- // At edge of row - don't wrap to next row on horizontal nav
- return null;
- }
-
- // Fallback: flat DOM order navigation
- 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) {
- const currentRect = this.currentFocus.getBoundingClientRect();
- const nextEl = allFocusable[nextIndex];
- const nextRect = nextEl.getBoundingClientRect();
-
- // Don't jump to next row on horizontal nav
- const verticalDist = Math.abs(currentRect.top - nextRect.top);
- if (verticalDist > currentRect.height * 0.5) {
- return null; // Would jump to different row
- }
- 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.3);
- } else { // Up
- return rect.bottom <= currentRect.top + (currentRect.height * 0.3);
- }
- });
-
- if (candidates.length === 0) return null;
-
- // Find the one with minimum distance, prioritizing horizontal alignment
- let bestCandidate = null;
- let minDistance = Infinity;
-
- candidates.forEach(el => {
- const rect = el.getBoundingClientRect();
- const elCenterX = rect.left + rect.width / 2;
-
- // Vertical distance (primary)
- const vDist = Math.abs(rect.top - currentRect.top);
-
- // Horizontal alignment penalty (prefer elements at similar X position)
- const hDist = Math.abs(elCenterX - centerX);
-
- // Weight: prefer closer rows, then prefer horizontal alignment
- // Vertical distance matters more for row-based navigation
- const dist = vDist * 2 + hDist;
-
- if (dist < minDistance) {
- minDistance = dist;
- bestCandidate = el;
- }
- });
-
- return bestCandidate;
- }
-}
diff --git a/frontend/scripts/main.js b/frontend/scripts/main.js
deleted file mode 100755
index 01f4885..0000000
--- a/frontend/scripts/main.js
+++ /dev/null
@@ -1,3219 +0,0 @@
-/**
- * KV-Netflix - Main Application Entry Point
- * Initializes the video streaming application
- */
-
-import { api } from './api.js';
-import { createVideoCard } from './components/VideoCard.js';
-import { initPlayer, destroyPlayer } from './components/VideoPlayer.js';
-import { initSearch } from './components/SearchBar.js';
-import { showToast } from './components/Toast.js';
-
-import { createInfoModal } from './components/InfoModal.js';
-import { renderNewAndHotView } from './components/NewAndHot.js';
-import { KeyboardNavigation } from './keyboard-nav.js';
-import { hapticLight, hapticMedium, hapticSuccess } from './haptics.js';
-import { StatusBar, Style } from '../js/capacitor-mock.js';
-
-/**
- * SplashScreen Controller
- * Manages loading progress and cinematic transition
- */
-const SplashScreen = {
- elements: {
- overlay: document.getElementById('splash-screen'),
- bar: document.getElementById('loading-bar'),
- text: document.getElementById('loading-text')
- },
- progress: 0,
- isFinished: false,
-
- update(percent, message) {
- if (this.isFinished) return;
- this.progress = Math.min(percent, 100);
- if (this.elements.bar) this.elements.bar.style.width = `${this.progress}%`;
- if (this.elements.text && message) this.elements.text.textContent = message;
-
- if (this.progress >= 100) {
- this.finish();
- }
- },
-
- finish() {
- if (this.isFinished) return;
- this.isFinished = true;
- setTimeout(() => {
- if (this.elements.overlay) {
- this.elements.overlay.classList.add('fade-out');
- // Remove from DOM after transition to free up resources
- setTimeout(() => this.elements.overlay.remove(), 1000);
- }
- }, 500);
- }
-};
-// Drag scroll removed per user request
-// Application state
-const state = {
- videos: [],
- currentCategory: 'all',
- currentVideo: null,
- isLoading: false,
- featuredVideo: null,
- heroMovies: [],
- currentHeroIndex: 0,
- heroInterval: null,
- page: 1,
- hasMore: true
-};
-
-// DOM elements
-const elements = {
- // Use videoGrid if exists, otherwise fall back to mainContent (Tailwind CSS design)
- videoGrid: document.getElementById('videoGrid') || document.getElementById('mainContent'),
- mainContent: document.getElementById('mainContent'),
- loading: document.getElementById('loading'),
- emptyState: document.getElementById('emptyState'),
- categories: document.getElementById('categories'),
- // Netflix-style navigation elements
- mainHeader: document.getElementById('mainHeader'),
- searchWrapper: document.getElementById('searchWrapper'),
- searchToggle: document.getElementById('searchToggle'),
- searchInput: document.getElementById('searchInput'),
- searchResults: document.getElementById('searchResults'),
- navLinks: document.querySelectorAll('.header__nav-link'),
-
- playerModal: document.getElementById('playerModal'),
- playerContainer: document.getElementById('playerContainer'),
- playerTitle: document.getElementById('playerTitle'),
- playerMeta: document.getElementById('playerMeta'),
- closePlayer: document.getElementById('closePlayer'),
- modalBackdrop: document.getElementById('modalBackdrop'),
- mobileNavItems: document.querySelectorAll('.mobile-nav__item, .sidebar__nav-item'),
- mobileBottomNavButtons: document.querySelectorAll('#mobileBottomNav .nav-item')
-};
-
-/**
- * Set the active state of mobile bottom navigation
- * @param {string} viewName - 'home', 'cinema', 'mylist', or 'search'
- */
-function setMobileNavActive(viewName) {
- const navButtons = document.querySelectorAll('#mobileBottomNav .nav-item');
- navButtons.forEach(btn => {
- const isActive = btn.dataset.view === viewName;
- btn.classList.toggle('active', isActive);
- btn.classList.toggle('text-white', isActive);
- btn.classList.toggle('text-gray-400', !isActive);
-
- const icon = btn.querySelector('.material-symbols-outlined');
- if (icon) {
- icon.style.fontVariationSettings = isActive ? "'FILL' 1" : "'FILL' 0";
- }
- });
-}
-
-
-/**
- * Initialize the application
- */
-async function init() {
- SplashScreen.update(10, 'Initializing services...');
-
- // Initialize search
- initSearch(elements.searchInput, elements.searchResults, handleVideoPlay);
- SplashScreen.update(20, 'Setting up navigation...');
-
- // Initialize Mobile Bottom Nav
- if (elements.mobileBottomNavButtons) {
- // ... (existing button logic)
- elements.mobileBottomNavButtons.forEach(btn => {
- btn.addEventListener('click', (e) => {
- e.preventDefault();
- const view = btn.dataset.view;
- if (!view) return;
-
- // Update active state
- elements.mobileBottomNavButtons.forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
-
- // Native Haptic
- hapticLight();
-
- // Handle routing
- if (view === 'home') {
- renderHome();
- // Scroll to top for home
- window.scrollTo({ top: 0, behavior: 'smooth' });
- } else if (view === 'search') {
- // Mobile Search View - don't scroll to top
- if (window.innerWidth < 768) {
- try {
- renderMobileSearch();
- } catch (e) {
- console.error('Search render failed', e);
- }
- } else {
- elements.searchWrapper.classList.add('active');
- elements.searchInput.focus();
- }
- } else if (view === 'mylist') {
- // My List View - don't scroll to top
- if (window.innerWidth < 768) {
- renderMobileMyList();
- } else {
- renderHistoryView('mylist');
- }
- } else if (view === 'downloads') {
- showToast('Downloads feature coming soon!', 'info');
- } else if (view === 'profile') {
- renderProfileView();
- } else if (view === 'cinema') {
- setMobileNavActive('cinema');
- renderCategoryView('cinema');
- // Scroll to top for category views
- window.scrollTo({ top: 0, behavior: 'smooth' });
- } else {
- renderCategoryView(view);
- // Scroll to top for category views
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }
- });
- });
- }
-
- // Set up event listeners
- setupEventListeners();
- SplashScreen.update(40, 'Fetching movie catalog...');
-
- // Load home view with organized sections
- try {
- await renderCategoryView('home');
- } catch (e) {
- console.error('Home render failed', e);
- }
- SplashScreen.update(70, 'Preparing featured content...');
-
- // Render hero with featured content
- try {
- await renderHero();
- } catch (e) {
- console.error('Hero render failed', e);
- }
- SplashScreen.update(90, 'Applying final touches...');
-
- // Handle view parameter from URL (e.g. for redirects from watch page)
- const urlParams = new URLSearchParams(window.location.search);
- const viewParam = urlParams.get('view');
- if (viewParam && window.innerWidth < 768) {
- if (viewParam === 'search') renderMobileSearch();
- else if (viewParam === 'mylist') renderMobileMyList();
- else if (viewParam === 'cinema') renderCategoryView('cinema');
- }
-
- // Initialize TV-Style Keyboard Navigation
- const nav = new KeyboardNavigation();
- nav.init();
-
- // Register PWA Service Worker
- if ('serviceWorker' in navigator) {
- window.addEventListener('load', () => {
- navigator.serviceWorker.register('/sw.js')
- });
- }
-
- SplashScreen.update(100, 'Welcome to StreamFlix');
-
- // Initialize Native Status Bar
- try {
- await StatusBar.setStyle({ style: Style.Dark });
- await StatusBar.setBackgroundColor({ color: '#141414' });
- } catch (e) {
- // Fail silently
- }
-}
-
-/**
- * Render hero section with featured movie
- * @param {Object} video - Optional video object to render (defaults to state.featuredVideo)
- */
-function renderHero(video = null) {
- const heroTitle = document.getElementById('heroTitle');
- const heroDescription = document.getElementById('heroDescription');
- const heroBg = document.getElementById('heroBg');
- const heroTag = document.getElementById('heroTag');
- const heroTagContainer = document.getElementById('heroTagContainer');
- const heroPlayBtn = document.getElementById('heroPlayBtn');
- const heroInfoBtn = document.getElementById('heroInfoBtn');
- const heroContent = document.getElementById('heroContent');
-
- // Get featured video (param, or state.featuredVideo, or first video)
- const featured = video || state.featuredVideo || state.videos[0];
-
- if (!featured) {
- return;
- }
-
- // Add fade out effect
- if (heroBg) heroBg.style.opacity = '0.5';
- if (heroContent) heroContent.style.opacity = '0';
-
- setTimeout(() => {
- // Update hero content
- if (heroTitle) heroTitle.textContent = featured.name || featured.title || 'Featured Movie';
- if (heroDescription) heroDescription.textContent = featured.description || featured.content || 'Watch now on StreamFlix';
-
- // Set background
- const backdrop = featured.backdrop || featured.poster_url || featured.thumb_url || featured.thumbnail || '';
- if (heroBg && backdrop) {
- heroBg.style.backgroundImage = `url('${backdrop}')`;
- }
-
- // Set category tag
- if (heroTag && heroTagContainer) {
- const genres = featured.genres || featured.category;
-
- // Unhide container
- heroTagContainer.classList.remove('hidden');
-
- if (genres && Array.isArray(genres) && genres.length > 0) {
- heroTag.textContent = genres[0];
- } else if (typeof genres === 'string') {
- heroTag.textContent = genres;
- } else {
- heroTag.textContent = '#1 in Movies Today';
- }
- }
-
- // Play button
- // Remove old listeners to prevent stacking
- if (heroPlayBtn && heroPlayBtn.parentNode) {
- const newPlayBtn = heroPlayBtn.cloneNode(true);
- heroPlayBtn.parentNode.replaceChild(newPlayBtn, heroPlayBtn);
- newPlayBtn.addEventListener('click', () => {
- hapticMedium();
- handleVideoPlay(featured);
- });
- }
-
- // Info button
- if (heroInfoBtn && heroInfoBtn.parentNode) {
- const newInfoBtn = heroInfoBtn.cloneNode(true);
- heroInfoBtn.parentNode.replaceChild(newInfoBtn, heroInfoBtn);
- newInfoBtn.addEventListener('click', () => handleShowInfo(featured));
- }
-
- // Fade in
- if (heroBg) heroBg.style.opacity = '1';
- if (heroContent) heroContent.style.opacity = '1';
- }, 300);
-
- state.featuredVideo = featured;
-}
-
-/**
- * Start Hero Carousel
- */
-function startHeroCarousel() {
- if (state.heroInterval) clearInterval(state.heroInterval);
-
- // Only start if we have multiple movies
- if (!state.heroMovies || state.heroMovies.length <= 1) return;
-
- state.heroInterval = setInterval(() => {
- state.currentHeroIndex++;
- if (state.currentHeroIndex >= state.heroMovies.length) {
- state.currentHeroIndex = 0;
- }
- renderHero(state.heroMovies[state.currentHeroIndex]);
- }, 8000); // 8 seconds
-}
-
-/**
- * Stop Hero Carousel
- */
-function stopHeroCarousel() {
- if (state.heroInterval) {
- clearInterval(state.heroInterval);
- state.heroInterval = null;
- }
-}
-
-/**
- * Set up event listeners
- */
-function setupEventListeners() {
- // Header Scroll Effect - Master Instruction Logic
- const backToTopBtn = document.getElementById('backToTop');
- const handleScroll = () => {
- const scrollY = window.scrollY;
-
- // Header background change
- if (elements.mainHeader) {
- if (scrollY > 100) {
- elements.mainHeader.classList.add('scrolled');
- elements.mainHeader.style.backgroundColor = '#141414'; // Strict Netflix Black
- } else {
- elements.mainHeader.classList.remove('scrolled');
- elements.mainHeader.style.backgroundColor = 'transparent'; // Gradient handled by CSS
- }
- }
-
- // Back to top button visibility
- if (backToTopBtn) {
- if (scrollY > 500) {
- backToTopBtn.classList.add('visible');
- } else {
- backToTopBtn.classList.remove('visible');
- }
- }
- };
- window.addEventListener('scroll', handleScroll, { passive: true });
- // Initial check
- handleScroll();
-
- // Back to Top Button Click Handler
- if (backToTopBtn) {
- backToTopBtn.addEventListener('click', () => {
- window.scrollTo({
- top: 0,
- behavior: 'smooth'
- });
- });
- }
-
- // Expandable Search Logic - REMOVED (Unifying with Modal)
-
- // Category Navigation
- elements.navLinks?.forEach(link => {
- link.addEventListener('click', (e) => {
- e.preventDefault();
- const category = link.dataset.category;
-
- // Update active state
- elements.navLinks.forEach(l => l.classList.remove('active'));
- link.classList.add('active');
-
- // Load content for category
- state.currentCategory = category;
- loadVideos(category, true); // true = reset pagination
- });
- });
-
- // Mobile & Sidebar Navigation
- elements.mobileNavItems?.forEach(item => {
- item.addEventListener('click', (e) => {
- e.preventDefault();
- const view = item.dataset.view;
-
- // Update active state
- elements.mobileNavItems.forEach(i => i.classList.remove('active'));
- // If it's a sidebar item, we might need to activate all matching items (desktop + mobile logic if split)
- // For now just activate clicked
- item.classList.add('active');
-
- // Sync other items with same view (e.g. if both mobile nav and sidebar exist)
- elements.mobileNavItems.forEach(i => {
- if (i.dataset.view === view) i.classList.add('active');
- });
-
- if (view === 'home') {
- elements.videoGrid.style.display = 'block';
- const newHot = document.getElementById('newHotContainer');
- if (newHot) newHot.style.display = 'none';
- state.currentCategory = 'all';
- loadVideos('all', true);
- } else if (['movies', 'series', 'animation', 'cinema'].includes(view)) {
- // Category Views
- elements.videoGrid.style.display = 'block';
- const newHot = document.getElementById('newHotContainer');
- if (newHot) newHot.style.display = 'none';
-
- state.currentCategory = view;
- loadVideos(view, true); // loadVideos handles the API call with category param
- } else if (view === 'history') {
- // History & My List View (SPA)
- elements.videoGrid.style.display = 'block';
- const newHot = document.getElementById('newHotContainer');
- if (newHot) newHot.style.display = 'none';
- renderHistoryView();
- } else if (view === 'search') {
- // Trigger search modal instead of legacy view
- const searchBtn = document.getElementById('headerSearchBtn');
- if (searchBtn) searchBtn.click();
- }
-
- // Roll back to hero banner (scroll to top)
- window.scrollTo({ top: 0, behavior: 'smooth' });
- });
- });
-
- // Netflix Header Navigation (Desktop Top Nav)
- const netflixNavLinks = document.querySelectorAll('.netflix-header__nav-link');
- netflixNavLinks.forEach(link => {
- link.addEventListener('click', (e) => {
- e.preventDefault();
- const view = link.dataset.view;
-
- // Update active state on Netflix header
- netflixNavLinks.forEach(l => l.classList.remove('active'));
- link.classList.add('active');
-
- // Sync sidebar/mobile items
- elements.mobileNavItems.forEach(i => {
- i.classList.remove('active');
- if (i.dataset.view === view) i.classList.add('active');
- });
-
- // Handle view switching
- elements.videoGrid.style.display = 'block';
- const newHot = document.getElementById('newHotContainer');
- if (newHot) newHot.style.display = 'none';
-
- if (view === 'home') {
- state.currentCategory = 'all';
- loadVideos('all', true);
- } else if (['movies', 'series', 'animation', 'cinema'].includes(view)) {
- state.currentCategory = view;
- loadVideos(view, true);
- } else if (view === 'history') {
- renderHistoryView();
- }
-
- // Roll back to hero banner (scroll to top)
- window.scrollTo({ top: 0, behavior: 'smooth' });
- });
- });
-
- // Netflix Header Search Button
- const headerSearchBtn = document.getElementById('headerSearchBtn');
- if (headerSearchBtn) {
- headerSearchBtn.addEventListener('click', (e) => {
- e.preventDefault();
- const searchModal = document.getElementById('searchModal');
- const searchInput = document.getElementById('searchInput');
- if (searchModal) {
- searchModal.classList.add('active');
- if (searchInput) setTimeout(() => searchInput.focus(), 100);
- }
- });
- }
-
- // Mobile Search Button
- const mobileSearchBtn = document.getElementById('mobileSearchBtn');
- if (mobileSearchBtn) {
- mobileSearchBtn.addEventListener('click', (e) => {
- e.preventDefault();
- const searchModal = document.getElementById('searchModal');
- const searchInput = document.getElementById('searchInput');
- if (searchModal) {
- hapticLight();
- searchModal.classList.add('active');
- if (searchInput) setTimeout(() => searchInput.focus(), 100);
- }
- });
- }
-
- // Close Search Modal
- const closeSearch = document.getElementById('closeSearch');
- if (closeSearch) {
- closeSearch.addEventListener('click', () => {
- const searchModal = document.getElementById('searchModal');
- if (searchModal) searchModal.classList.remove('active');
- });
- }
-
- // Modal Player Back Button
- const modalPlayerBackButton = document.getElementById('modalPlayerBackButton');
- if (modalPlayerBackButton) {
- modalPlayerBackButton.addEventListener('click', () => {
- hapticLight();
- if (window.history.state?.playerOpen) {
- window.history.back();
- } else {
- closePlayerModal();
- }
- });
- }
-
- // Global Popstate for Modal Player
- window.addEventListener('popstate', (event) => {
- if (elements.playerModal?.classList.contains('active') && !event.state?.playerOpen) {
- closePlayerModal(false);
- }
- });
-
- // StreamFlix Nav Links (Tailwind design)
- const streamflixNavLinks = document.querySelectorAll('.nav-link');
- streamflixNavLinks.forEach(link => {
- link.addEventListener('click', (e) => {
- // Allow links with real href (not '#') to navigate normally
- const href = link.getAttribute('href');
- if (href && href !== '#' && !href.startsWith('#')) {
- // This is a real link (like Install App), let it navigate
- return;
- }
-
- e.preventDefault();
- const view = link.dataset.view;
-
- // Update active state
- streamflixNavLinks.forEach(l => {
- l.classList.remove('active', 'text-white');
- l.classList.add('text-gray-300');
- });
- link.classList.add('active', 'text-white');
- link.classList.remove('text-gray-300');
-
- // Handle view switching with organized category sections
- if (view === 'home') {
- state.currentCategory = 'all';
- renderCategoryView('home');
- } else if (view === 'series') {
- state.currentCategory = 'series';
- renderCategoryView('series');
- } else if (view === 'movies') {
- state.currentCategory = 'movies';
- renderCategoryView('movies');
- } else if (view === 'cinema') {
- state.currentCategory = 'cinema';
- renderCategoryView('cinema');
- } else if (view === 'history') {
- renderHistoryView();
- }
-
- // Roll back to hero banner (scroll to top)
- window.scrollTo({ top: 0, behavior: 'smooth' });
- });
- });
-
- // Modal close events
- elements.closePlayer?.addEventListener('click', closePlayerModal);
- elements.modalBackdrop?.addEventListener('click', closePlayerModal);
-
-
- // Keyboard shortcuts
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- if (elements.playerModal?.classList.contains('active')) {
- if (window.history.state?.playerOpen) {
- window.history.back();
- } else {
- closePlayerModal();
- }
- }
- if (elements.searchWrapper?.classList.contains('active')) {
- elements.searchWrapper.classList.remove('active');
- }
- // Close search modal
- const searchModal = document.getElementById('searchModal');
- if (searchModal?.classList.contains('active')) {
- searchModal.classList.remove('active');
- }
- }
- });
-}
-
-/**
- * Load videos from API - tries RoPhim first, then database, then demo
- * @param {string} category - Optional category filter
- * @param {boolean} reset - Whether to reset pagination (e.g. category change)
- */
-async function loadVideos(category = 'all', reset = false) {
- if (state.isLoading) return;
- if (reset) {
- state.page = 1;
- state.hasMore = true;
- state.videos = [];
- elements.videoGrid.innerHTML = '';
- }
-
- if (!state.hasMore) return;
-
- state.isLoading = true;
- showLoading(state.page === 1); // Only show full loader on first page
-
- // Helper function to add timeout to fetch
- const fetchWithTimeout = (promise, timeout = 12000) => {
- return Promise.race([
- promise,
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('Timeout')), timeout)
- )
- ]);
- };
-
- // Top Search Button
- const topSearchBtn = document.getElementById('topSearchBtn');
- if (topSearchBtn) {
- topSearchBtn.addEventListener('click', (e) => {
- e.preventDefault();
- const searchModal = document.getElementById('searchModal');
- const searchInput = document.getElementById('searchInput');
- if (searchModal) {
- searchModal.classList.add('active');
- if (searchInput) setTimeout(() => searchInput.focus(), 100);
- }
- });
- }
-
- try {
-
- let apiResponse = null;
- let isSectionMode = false;
-
- // Section Mode disabled to force Responsive Grid Layout per user request
-
-
- // Fallback: Flat Catalog
- if (!apiResponse) {
- apiResponse = await fetchWithTimeout(
- api.getRophimCatalog({
- category: category !== 'all' ? category : null,
- page: state.page,
- limit: 24
- }), 12000
- );
- }
-
-
-
- if (apiResponse && apiResponse.movies && apiResponse.movies.length > 0) {
-
- // Map API data to Video objects
- const newVideos = apiResponse.movies.map(m => ({
- id: m.id || `api_${Date.now()}_${Math.random()}`,
- title: m.title || 'Unknown Title',
- thumbnail: m.thumbnail || 'https://via.placeholder.com/300x450?text=No+Image',
- backdrop: m.backdrop || m.thumbnail || 'https://via.placeholder.com/1920x1080?text=No+Backdrop',
- preview_url: m.preview_url || '',
- duration: m.duration || 0,
- resolution: m.quality || 'HD',
- category: m.category || 'movies',
- year: m.year || new Date().getFullYear(),
- description: m.description || '',
- matchScore: Math.floor(Math.random() * 15) + 85, // Random high match score
- source_url: m.source_url,
- slug: m.slug,
- // Rich Metadata
- cast: m.cast || [],
- director: m.director,
- country: m.country,
- episodes: m.episodes || []
- }));
-
- // Append new videos
- // Deduplicate based on ID or slug
- const existingIds = new Set(state.videos.map(v => v.id));
- const uniqueNewVideos = newVideos.filter(v => !existingIds.has(v.id));
-
- state.videos = [...state.videos, ...uniqueNewVideos];
- state.page += 1; // Increment page for next fetch
-
- // Detect if we reached end of content? (Usually API returns empty list, but here we got items)
- if (newVideos.length < 24) {
- // state.hasMore = false; // Optional optimization
- }
-
-
- // Force Responsive Grid Layout for ALL categories
- const isFirstBatch = state.page === 2; // Page bumped after fetch
- if (isFirstBatch) {
- renderVideoGrid(state.videos, false);
- } else {
- renderVideoGrid(uniqueNewVideos, true);
- }
-
- // Preload featured video for Hero only on first load
-
-
- // Setup/Update Infinite Scroll trigger
- setupInfiniteScrollTrigger();
-
- // Hide loading state on sentinel
- if (scrollSentinel) scrollSentinel.classList.remove('loading');
-
- state.isLoading = false;
- showLoading(false);
- return;
- } else {
- state.hasMore = false;
- // Hide sentinel when no more content
- if (scrollSentinel) {
- scrollSentinel.classList.remove('loading');
- scrollSentinel.style.display = 'none';
- }
- state.isLoading = false;
- showLoading(false);
- }
-
- } catch (error) {
- console.warn('API load failed:', error);
- // Only fallback to demo on first page load
- if (state.page === 1) {
- showToast('Using offline mode', 'info');
- const demoVideos = getDemoContent();
- state.videos = demoVideos;
- state.featuredVideo = demoVideos[0];
- renderVideoGrid(demoVideos);
-
-
- }
- state.isLoading = false;
- showLoading(false);
- }
-}
-
-/**
- * Render content as horizontal sliders - PhimMoi Style
- * @param {Array} videos - Videos to group
- */
-/**
- * Render content as horizontal sliders - Apple TV+ Style
- * Enhanced with smart categorization and genre-based sections
- * @param {Array} videos - Videos to group
- */
-/**
- * Render content as horizontal sliders - Apple TV+ Style
- * Enhanced with smart categorization and genre-based sections
- * @param {Array} videos - Videos to group
- */
-function renderBackendSection(title, movies, isTop10, container = elements.videoGrid, usedIds = null) {
- if (!movies || movies.length === 0) return;
-
- // Deduplicate if set provided
- let uniqueMovies = movies;
- if (usedIds) {
- uniqueMovies = movies.filter(m => !usedIds.has(m.id || m.slug));
- if (uniqueMovies.length === 0) return;
- uniqueMovies.forEach(m => usedIds.add(m.id || m.slug));
- }
-
- // Normalize
- const normalizedVideo = uniqueMovies.map(m => ({
- id: m.id || m.slug,
- title: m.title,
- thumbnail: m.thumbnail,
- backdrop: m.backdrop || m.thumbnail,
- slug: m.slug,
- year: m.year,
- badge: m.badge,
- ranking: m.ranking
- }));
-
- const section = isTop10
- ? createTop10Section(title, normalizedVideo)
- : createSliderSection(title, normalizedVideo);
-
- container.appendChild(section);
-}
-
-async function renderSliders(videos) {
- elements.videoGrid.innerHTML = '';
- // Use Tailwind CSS layout classes
- elements.videoGrid.className = 'space-y-12';
-
- if (elements.emptyState) elements.emptyState.style.display = 'none';
-
- // 1. DISABLED: Curated sections API was overriding sectionConfigs.
- // Now using only sectionConfigs for full control over categories.
- /*
- try {
- const curatedResponse = await api.getCuratedSections();
-
- if (curatedResponse && curatedResponse.sections && curatedResponse.sections.length > 0) {
-
- curatedResponse.sections.forEach(section => {
- if (section.movies && section.movies.length > 0) {
- // Normalize movies
- const normalizedMovies = section.movies.map(m => ({
- id: m.id || m.slug,
- title: m.title,
- thumbnail: m.thumbnail,
- backdrop: m.poster_url || m.thumbnail,
- slug: m.slug,
- year: m.year,
- quality: m.quality || 'HD',
- resolution: m.quality || 'HD',
- rating: m.rating,
- tmdb_rating: m.tmdb_rating,
- genres: m.genres,
- category: m.category
- }));
-
- // Determine if this is a "Top Rated" section
- const isTopRated = section.title.includes('Top Rated') || section.title.includes('ðŸ†');
-
- const sectionEl = isTopRated
- ? createTop10Section(section.title, normalizedMovies)
- : createSliderSection(section.title, normalizedMovies);
- elements.videoGrid.appendChild(sectionEl);
- }
- });
-
- if (elements.videoGrid.children.length > 0) {
- return;
- }
- }
- } catch (e) {
- console.warn('Curated sections failed, trying backend categories...', e);
- }
- */
-
- // 2. DISABLED: Backend structured categories were also overriding sectionConfigs.
- // All rendering now happens in renderCategoryView() using sectionConfigs.
- /*
- try {
- let backendCategories = null;
-
- // Try categorySystem first, then direct API call
- if (window.categorySystem) {
- backendCategories = await window.categorySystem.loadCategories();
- }
-
- // Fallback: fetch directly from API
- if (!backendCategories) {
- const response = await fetch('/api/rophim/categories/all');
- const data = await response.json();
- backendCategories = data.categories;
- }
-
- if (backendCategories) {
-
- // Defined section order and titles
- const sectionConfig = [
- { key: 'hot', title: '🔥 Phim Hot (Movies)', isTop10: false },
- { key: 'top10', title: '🆠Top 10 Phim Lẻ', isTop10: true },
- { key: 'series', title: '📺 Phim Bộ Mới (Series)', isTop10: false },
- { key: 'cinema', title: '🿠Phim Chiếu Rạp', isTop10: false },
- { key: 'animated', title: '🎌 Hoạt Hình & Anime', isTop10: false },
- { key: 'vietnamese', title: '🇻🇳 Phim Việt Nam', isTop10: false },
- { key: 'tv_shows', title: '🎬 TV Shows', isTop10: false },
- { key: 'action', title: '💥 Action Movies', isTop10: false },
- { key: 'new_releases', title: '✨ Má»›i Cáºp Nháºt', isTop10: false }
- ];
-
- // Track used videos to prevent duplicates across sections
- const globalUsedIds = new Set();
-
- // Render sections in order
- sectionConfig.forEach(config => {
- if (backendCategories[config.key] && backendCategories[config.key].length > 0) {
- // Skip deduplication for Top 10 - it's a ranked section and should always show
- const skipDedup = config.isTop10;
- renderBackendSection(
- config.title,
- backendCategories[config.key],
- config.isTop10,
- elements.videoGrid,
- skipDedup ? null : globalUsedIds
- );
- }
- });
-
- // If we successfully rendered backend categories, return here
- if (elements.videoGrid.children.length > 0) {
- return;
- }
- }
- } catch (e) {
- console.warn('Failed to load backend categories, falling back to local logic', e);
- }
- */
-
- // --- FALLBACK / ORIGINAL LOGIC ---
-
- // Sort videos by year descending (newest first)
- videos.sort((a, b) => (b.year || 0) - (a.year || 0));
-
- // Track videos already added to sections to prevent duplicates
- const usedVideoIds = new Set();
-
- /**
- * Helper: Add videos to a section and track them
- */
- function addSection(title, videos, isTop10 = false) {
- if (!videos || videos.length === 0) return;
-
- // Filter out already-used videos
- const availableVideos = videos.filter(v => !usedVideoIds.has(v.id));
- if (availableVideos.length === 0) return;
-
- // Take up to 12 videos (or 10 for Top10)
- const limit = isTop10 ? 10 : 12;
- const sectionVideos = availableVideos.slice(0, limit);
-
- // Mark videos as used
- sectionVideos.forEach(v => usedVideoIds.add(v.id));
-
- // Create and append section
- const section = isTop10
- ? createTop10Section(title, sectionVideos)
- : createSliderSection(title, sectionVideos);
- elements.videoGrid.appendChild(section);
-
- }
-
- /**
- * Helper: Extract unique genres from videos
- */
- function extractGenres(videos) {
- const genreCounts = {};
- videos.forEach(v => {
- if (v.category && typeof v.category === 'string') {
- // Normalize category names
- const normalized = v.category.toLowerCase();
- const genreMap = {
- 'phim-le': 'Movies',
- 'phim-bo': 'Series',
- 'hoat-hinh': 'Animation',
- 'tv-shows': 'TV Shows'
- };
- const genre = genreMap[normalized] || v.category;
- genreCounts[genre] = (genreCounts[genre] || 0) + 1;
- }
- });
- return genreCounts;
- }
-
- // ==========================================
- // PRIORITY SECTION 1: Featured/Top Content
- // ==========================================
- addSection('Top Charts: Movies', videos, true);
-
- // ==========================================
- // PRIORITY SECTION 2: Year-Based (Newest First)
- // ==========================================
- const currentYear = new Date().getFullYear();
-
- addSection('2024 New Releases',
- videos.filter(v => v.year === currentYear));
-
- addSection('2023 Hits',
- videos.filter(v => v.year === currentYear - 1));
-
- // ==========================================
- // PRIORITY SECTION 3: Quality-Based
- // ==========================================
- addSection('4K Ultra HD',
- videos.filter(v => v.resolution === '4K' || v.quality === '4K'));
-
- // ==========================================
- // PRIORITY SECTION 4: Category-Based
- // ==========================================
- addSection('Must-Watch Series',
- videos.filter(v => v.category === 'series' || v.category === 'phim-bo' || v.category === 'tv-shows'));
-
- addSection('Anime & Animation',
- videos.filter(v => v.category === 'anime' || v.category === 'hoat-hinh'));
-
- addSection('Action & Blockbusters',
- videos.filter(v => v.category === 'movies' || v.category === 'theater' || v.category === 'phim-le'));
-
- // ==========================================
- // PRIORITY SECTION 5: Country/Region-Based
- // ==========================================
- addSection('Korean Cinema',
- videos.filter(v => v.country && (v.country.includes('Korea') || v.country.includes('Hà n Quốc'))));
-
- addSection('Japanese Films',
- videos.filter(v => v.country && (v.country.includes('Japan') || v.country.includes('Nháºt Bản'))));
-
- addSection('Hollywood Blockbusters',
- videos.filter(v => v.country && (v.country.includes('US') || v.country.includes('USA') || v.country.includes('Mỹ'))));
-
- addSection('European Collection',
- videos.filter(v => v.country && (
- v.country.includes('UK') || v.country.includes('France') ||
- v.country.includes('Germany') || v.country.includes('Spain') ||
- v.country.includes('Anh') || v.country.includes('Pháp') || v.country.includes('Äức')
- )));
-
- addSection('Asian Cinema',
- videos.filter(v => v.country && (
- v.country.includes('China') || v.country.includes('Thailand') ||
- v.country.includes('Hong Kong') || v.country.includes('Taiwan') ||
- v.country.includes('Trung Quốc') || v.country.includes('Thái Lan') || v.country.includes('Hồng Kông')
- )));
-
- // ==========================================
- // PRIORITY SECTION 6: Time-Period Based
- // ==========================================
- addSection('Recent Favorites (2020-2022)',
- videos.filter(v => v.year && v.year >= 2020 && v.year <= 2022));
-
- addSection('Modern Classics (2015-2019)',
- videos.filter(v => v.year && v.year >= 2015 && v.year < 2020));
-
- addSection('Timeless Classics',
- videos.filter(v => v.year && v.year < 2015));
-
- // ==========================================
- // PRIORITY SECTION 7: Dynamic Genre Sections
- // ==========================================
- // Get remaining unused videos
- const unusedVideos = videos.filter(v => !usedVideoIds.has(v.id));
-
- if (unusedVideos.length > 0) {
- const genreCounts = extractGenres(unusedVideos);
-
- // Sort genres by count and create sections for top genres
- const sortedGenres = Object.entries(genreCounts)
- .sort((a, b) => b[1] - a[1])
- .slice(0, 5);
-
- sortedGenres.forEach(([genre, count]) => {
- if (count >= 6) {
- addSection(`${genre} Collection`,
- unusedVideos.filter(v => v.category === genre || v.category?.toLowerCase().includes(genre.toLowerCase())));
- }
- });
- }
-
- // ==========================================
- // FINAL FALLBACK: Hidden Gems (minimal)
- // ==========================================
- const stillUnused = videos.filter(v => !usedVideoIds.has(v.id));
-
- if (stillUnused.length >= 6) {
- addSection('Hidden Gems', stillUnused);
- }
-
- // Log categorization summary
-}
-
-/**
- * Create a Top 10 Section with numbered rankings - PhimMoi Style
- */
-function createTop10Section(title, videos) {
- const section = document.createElement('section');
- section.className = 'slider-section top10-section';
-
- section.innerHTML = `
- ${title}
-
- `;
-
- const track = section.querySelector('.slider-track');
- videos.slice(0, 10).forEach((video, index) => {
- const card = createRankedCard(video, index + 1);
- track.appendChild(card);
- });
-
- // Mouse drag scrolling removed per user request
-
- // Slider Logic
- const btnLeft = section.querySelector('.slider-btn--left');
- const btnRight = section.querySelector('.slider-btn--right');
-
- btnRight.addEventListener('click', () => {
- track.scrollBy({ left: window.innerWidth * 0.6, behavior: 'smooth' });
- });
-
- btnLeft.addEventListener('click', () => {
- track.scrollBy({ left: -window.innerWidth * 0.6, behavior: 'smooth' });
- });
-
- return section;
-}
-
-/**
- * Create a ranked card with number - PhimMoi Top 10 style
- */
-function createRankedCard(video, rank) {
- const card = document.createElement('div');
- card.className = 'ranked-card';
- card.innerHTML = `
- ${rank}
-
-

-
${video.resolution || 'HD'}
-
-
-
${video.title}
-
${video.year || ''} • ${video.country || ''}
-
- `;
-
- return card;
-}
-
-/**
- * Create a Slider Section - Netflix-style Horizontal Scroll (Tailwind CSS)
- */
-/**
- * Create a Horizontal Slider Section with scroll arrows
- */
-function createSliderSection(title, videos, cardType = 'poster') {
- const section = document.createElement('section');
- section.className = 'flex flex-col gap-4 mb-12 relative';
-
- // Section Header
- const header = document.createElement('h2');
- header.className = 'text-xl md:text-2xl font-bold text-white hover:text-primary cursor-pointer transition-colors flex items-center gap-2 group px-4 md:px-12';
- header.innerHTML = `
- ${title}
- arrow_forward_ios
- `;
- section.appendChild(header);
-
- // Slider wrapper (for positioning arrows)
- const sliderWrapper = document.createElement('div');
- sliderWrapper.className = 'relative group/slider';
-
- // Left Arrow Button
- const leftBtn = document.createElement('button');
- leftBtn.className = 'absolute left-0 top-1/2 -translate-y-1/2 z-20 w-12 h-full bg-gradient-to-r from-black/80 to-transparent opacity-0 group-hover/slider:opacity-100 transition-opacity flex items-center justify-start pl-2';
- leftBtn.innerHTML = 'chevron_left';
-
- // Right Arrow Button
- const rightBtn = document.createElement('button');
- rightBtn.className = 'absolute right-0 top-1/2 -translate-y-1/2 z-20 w-12 h-full bg-gradient-to-l from-black/80 to-transparent opacity-0 group-hover/slider:opacity-100 transition-opacity flex items-center justify-end pr-2';
- rightBtn.innerHTML = 'chevron_right';
-
- // Horizontal Scroll Container - bigger cards
- const container = document.createElement('div');
- container.className = 'flex gap-3 overflow-x-auto scroll-smooth no-scrollbar px-4 md:px-12 pb-4';
-
- videos.forEach((video, index) => {
- let card;
- if (cardType === 'landscape') {
- card = createContinueWatchingCard(video);
- } else {
- // All cards use horizontal orientation with larger size
- card = createTailwindCard(video, false, 0, 'horizontal');
- }
- // Apply larger fixed width for cards in slider (bigger cards)
- card.className = card.className.replace('w-full', '');
- card.style.minWidth = '280px';
- card.style.maxWidth = '380px';
- card.style.flex = '0 0 auto';
- container.appendChild(card);
- });
-
- // Scroll functionality
- const scrollAmount = 600;
- leftBtn.addEventListener('click', () => {
- container.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
- });
- rightBtn.addEventListener('click', () => {
- container.scrollBy({ left: scrollAmount, behavior: 'smooth' });
- });
-
- sliderWrapper.appendChild(leftBtn);
- sliderWrapper.appendChild(container);
- sliderWrapper.appendChild(rightBtn);
- section.appendChild(sliderWrapper);
-
- return section;
-}
-
-/**
- * Create a movie/poster card with Tailwind CSS (Netflix style)
- */
-/**
- * Create a movie/poster card with Tailwind CSS (Netflix strict style)
- */
-/**
- * Create a movie/poster card with Tailwind CSS (Netflix strict style)
- */
-/**
- * Create a movie/poster card with Tailwind CSS (Netflix strict style)
- * @param {Object} video - Video object
- * @param {boolean} showRank - Show ranking number
- * @param {number} rank - Rank number
- * @param {string} orientation - 'vertical' or 'horizontal'
- */
-function createTailwindCard(video, showRank = false, rank = 0, orientation = 'vertical') {
- const card = document.createElement('div');
-
- // Let grid control width; aspect ratio for sizing
- const aspectClass = orientation === 'horizontal' ? 'aspect-video' : 'aspect-[2/3]';
-
- // Use w-full to fill grid cell, no fixed width
- card.className = `w-full cursor-pointer snap-start group relative transition-all duration-300 ease-in-out hover:z-30 hover:scale-105`;
-
- // Prioritize backdrop for horizontal cards
- let image = video.poster_url || video.thumb_url || video.thumbnail || '';
- if (orientation === 'horizontal' && video.backdrop) {
- image = video.backdrop;
- }
-
- // PERFORMANCE: Use image proxy with optimized sizes (quality vs speed balance)
- const isMobile = window.innerWidth < 768;
- const imageWidth = isMobile ? 180 : (orientation === 'horizontal' ? 400 : 200);
- const proxiedImage = image ? api.getProxyUrl(image, imageWidth) : '';
-
- const title = video.name || video.title || 'Untitled';
- const year = video.year || '';
- const quality = video.quality || 'HD';
- const slug = video.slug || video.id || '';
-
- // Random match score for visual fidelity (90-99%)
- const matchScore = video.matchScore || Math.floor(Math.random() * (99 - 90 + 1) + 90);
-
- // Simulate Rotten Tomatoes (random 80-98%)
- const rtScore = Math.floor(Math.random() * (98 - 80 + 1) + 80);
-
- card.innerHTML = `
-
-
-
-
-
-
-
-
- ${!showRank && year === new Date().getFullYear().toString() ? `NEW` : ''}
- ${video.quality ? `${video.quality.replace('FHD', 'HD')}` : ''}
- ${video.current_episode ? `EP ${video.current_episode}` : ''}
-
-
-
- ${showRank ? `
${rank}` : ''}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${matchScore}% Match
- ${quality}
- ${year}
-
-
-
-
-
-
- local_pizza ${rtScore}%
-
-
- ${video.genres && video.genres.length > 0 ? `
${video.genres[0]}` : ''}
-
-
-
- ${title}
-
-
-
-
- `;
-
- // Click handler for play (background click)
- card.addEventListener('click', (e) => {
- if (!e.target.closest('button')) {
- handleVideoPlay(video);
- }
- });
-
- // Button Handlers
- const playBtn = card.querySelector('.btn-play');
- if (playBtn) playBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- handleVideoPlay(video);
- });
-
- const addBtn = card.querySelector('.btn-add-list');
- if (addBtn) addBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- if (window.historyService) {
- const added = window.historyService.toggleFavorite(video);
- // Visual toggle
- const icon = addBtn.querySelector('span');
- if (added) {
- icon.textContent = 'check';
- showToast('Added to My List', 'success');
- } else {
- icon.textContent = 'add';
- showToast('Removed from My List', 'info');
- }
- }
- });
-
- const infoBtn = card.querySelector('.btn-info');
- if (infoBtn) infoBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- // Updated to use the new navigation logical as per previous request
- handleShowInfo(video);
- });
-
- return card;
-}
-
-/**
- * Create a Continue Watching card (landscape with progress bar)
- */
-/**
- * Create a Continue Watching card (strict fit per preset)
- */
-function createContinueWatchingCard(video) {
- const card = document.createElement('div');
- card.className = 'flex-none w-[280px] group/card cursor-pointer snap-start';
-
- const poster = video.backdrop || video.thumb_url || video.thumbnail || '';
- const title = video.name || video.title || 'Untitled';
- const progress = video.progress?.percentage || 0;
- const episode = video.progress?.episode ? `S${video.season || 1}:E${video.progress.episode}` : '';
-
- card.innerHTML = `
-
-
- ${title}
- ${episode ? `${episode}` : ''}
-
- `;
-
- card.addEventListener('click', () => handleVideoPlay(video));
- return card;
-}
-
-
-
-
-/**
- * Render video grid (standard grid for search/categories)
- * @param {Array} videos - Array of video objects
- * @param {boolean} append - Whether to append to existing grid
- */
-function renderVideoGrid(videos, append = false) {
- // If not appending, clear the grid
- if (!append) {
- elements.videoGrid.innerHTML = '';
- elements.videoGrid.innerHTML = '';
- elements.videoGrid.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-x-4 gap-y-10';
- }
-
- if (videos.length === 0 && !append) {
- if (elements.emptyState) elements.emptyState.style.display = 'flex';
- return;
- }
-
- if (elements.emptyState) elements.emptyState.style.display = 'none';
-
- videos.forEach(video => {
- const card = createVideoCard(video, handleVideoPlay, handleShowInfo);
- elements.videoGrid.appendChild(card);
- });
-}
-
-function renderInfiniteGrid(videos) {
-
- if (!videos || videos.length === 0) {
- console.warn('No videos to render in infinite grid');
- return;
- }
-
- let infiniteContainer = document.getElementById('infinite-scroll-container');
- if (!infiniteContainer) {
- infiniteContainer = document.createElement('div');
- infiniteContainer.id = 'infinite-scroll-container';
- // Reduce top margin as the "Khám Phá Thêm" header from renderSliders already provides spacing
- infiniteContainer.style.marginTop = '1vw';
- elements.videoGrid.appendChild(infiniteContainer);
-
- // Removed redundant 'More to Explore' header here as it duplicates the one from renderSliders
- }
-
- // Group videos by year
- const currentYear = new Date().getFullYear();
- const moviesByYear = {};
-
- videos.forEach(video => {
- const year = video.year || currentYear;
- if (!moviesByYear[year]) {
- moviesByYear[year] = [];
- }
- moviesByYear[year].push(video);
- });
-
- // Sort years descending (newest first)
- const years = Object.keys(moviesByYear).sort((a, b) => b - a);
-
- // Create slider sections for each year
- let cardsAdded = 0;
- years.forEach(year => {
- const movies = moviesByYear[year];
- if (movies.length > 0) {
- const yearLabel = year == currentYear ? `${year} New Releases` :
- year == currentYear - 1 ? `${year} Hits` :
- `${year} Movies`;
- const section = createSliderSection(yearLabel, movies);
- infiniteContainer.appendChild(section);
- cardsAdded += movies.length;
- }
- });
-
-}
-
-let scrollObserver;
-let scrollSentinel = null;
-let lastScrollTrigger = 0; // Debounce timer
-
-function setupInfiniteScrollTrigger() {
- // If no more content, hide sentinel and don't set up observer
- if (!state.hasMore) {
- if (scrollSentinel) {
- scrollSentinel.classList.remove('loading');
- scrollSentinel.style.display = 'none';
- }
- if (scrollObserver) scrollObserver.disconnect();
- return;
- }
-
- if (scrollObserver) scrollObserver.disconnect();
-
- // Remove any existing sentinels first to prevent duplicates
- document.querySelectorAll('.scroll-sentinel').forEach(el => el.remove());
- scrollSentinel = null;
-
- const options = {
- root: null,
- rootMargin: '50px', // Reduced from 200px to prevent early triggering
- threshold: 0.0 // Trigger when any part is visible
- };
-
- scrollObserver = new IntersectionObserver((entries) => {
- entries.forEach(entry => {
- // Debounce: require at least 1.5 seconds between triggers
- const now = Date.now();
- if (now - lastScrollTrigger < 1500) {
- return;
- }
- if (entry.isIntersecting && !state.isLoading && state.hasMore) {
- lastScrollTrigger = now;
- // Show loading state on sentinel
- if (scrollSentinel) scrollSentinel.classList.add('loading');
- loadVideos(state.currentCategory);
- }
- });
- }, options);
-
- // Create single sentinel element
- scrollSentinel = document.createElement('div');
- scrollSentinel.className = 'scroll-sentinel';
- scrollSentinel.id = 'scrollSentinel';
-
- // Place sentinel at the proper location - after infinite container or at end of videoGrid
- const infiniteContainer = document.getElementById('infinite-scroll-container');
- if (infiniteContainer) {
- // Insert after the infinite container for proper positioning
- infiniteContainer.parentNode.insertBefore(scrollSentinel, infiniteContainer.nextSibling);
- } else {
- elements.videoGrid.appendChild(scrollSentinel);
- }
-
- scrollObserver.observe(scrollSentinel);
-}
-
-function handleShowInfo(video) {
- navigateToWatch(video);
-}
-
-/**
- * Render History View - Shows user's watch history and saved content
- * @param {string} tab - 'history' or 'mylist'
- */
-function renderHistoryView(tab = 'history') {
- if (elements.mainHeader) elements.mainHeader.style.display = '';
- if (!window.historyService) {
- console.error('HistoryService not initialized');
- return;
- }
-
- // Clear the grid
- elements.videoGrid.innerHTML = '';
- if (elements.emptyState) elements.emptyState.style.display = 'none';
-
- // Remove any existing history tabs
- const existingTabs = document.querySelector('.view-tabs');
- if (existingTabs) existingTabs.remove();
-
- // Create tabs for switching between History and My List
- const tabsContainer = document.createElement('div');
- tabsContainer.className = 'view-tabs';
- tabsContainer.innerHTML = `
-
-
- `;
- elements.videoGrid.before(tabsContainer);
-
- // Tab click listeners
- tabsContainer.querySelectorAll('.view-tab').forEach(btn => {
- btn.addEventListener('click', () => {
- tabsContainer.remove();
- renderHistoryView(btn.dataset.tab);
- });
- });
-
- let items = [];
- if (tab === 'history') {
- items = window.historyService.getHistory();
- } else {
- items = window.historyService.getFavorites();
- }
-
- if (items.length === 0) {
- if (elements.emptyState) {
- elements.emptyState.style.display = 'flex';
- const emptyTitle = elements.emptyState.querySelector('h2');
- const emptyDesc = elements.emptyState.querySelector('p');
-
- if (tab === 'history') {
- if (emptyTitle) emptyTitle.textContent = 'No history yet';
- if (emptyDesc) emptyDesc.textContent = 'Movies you watch will appear here.';
- } else {
- if (emptyTitle) emptyTitle.textContent = 'My List is empty';
- if (emptyDesc) emptyDesc.textContent = 'Add movies to your list to watch later.';
- }
- }
- return;
- }
-
- // 1. Sort by Latest (Year/Date)
- items.sort((a, b) => {
- const dateA = a.timestamp || a.year || 0;
- const dateB = b.timestamp || b.year || 0;
- return dateB - dateA;
- });
-
- // Normalize items with horizontal orientation for slider
- const normalizedItems = items.map((item, index) => {
- return {
- ...item,
- id: item.id || item.slug,
- orientation: 'horizontal'
- };
- });
-
- // Ensure header is shown
- if (elements.mainHeader) elements.mainHeader.style.display = 'block';
-
- // Use horizontal slider layout (same as home page)
- const title = tab === 'history' ? 'Continue Watching' : 'My List';
- const sliderSection = createSliderSection(title, normalizedItems, 'poster');
- elements.videoGrid.appendChild(sliderSection);
-}
-
-/**
- * Render Library View - Legacy fallback for history/favorites
- */
-function renderLibraryView() {
- renderHistoryView('mylist');
-}
-
-/**
- * Render Movies View - Shows movies in horizontal sliders organized by year
- */
-async function renderMoviesView() {
- // Show loading state
- showLoading(true);
-
- // Clear the grid
- elements.videoGrid.innerHTML = '';
- if (elements.emptyState) elements.emptyState.style.display = 'none';
-
- try {
- // Load movies if not already loaded
- if (state.videos.length === 0 || state.currentCategory !== 'movies') {
- state.currentCategory = 'movies';
- state.page = 1;
- state.hasMore = true;
-
- const apiResponse = await api.getRophimCatalog({
- category: 'phim-le', // movies category
- page: 1,
- limit: 48 // Load more movies for better categorization
- });
-
- if (apiResponse && apiResponse.movies && apiResponse.movies.length > 0) {
- state.videos = apiResponse.movies.map(m => ({
- id: m.id || `api_${Date.now()}_${Math.random()}`,
- title: m.title || 'Unknown Title',
- thumbnail: m.thumbnail || 'https://via.placeholder.com/300x450?text=No+Image',
- backdrop: m.backdrop || m.thumbnail || 'https://via.placeholder.com/1920x1080?text=No+Backdrop',
- preview_url: m.preview_url || '',
- duration: m.duration || 0,
- resolution: m.quality || 'HD',
- category: m.category || 'movies',
- year: m.year || new Date().getFullYear(),
- description: m.description || '',
- matchScore: Math.floor(Math.random() * 15) + 85,
- source_url: m.source_url,
- slug: m.slug,
- cast: m.cast || [],
- director: m.director,
- country: m.country,
- episodes: m.episodes || []
- }));
- }
- }
-
- // Group movies by year
- const moviesByYear = {};
- const currentYear = new Date().getFullYear();
-
- state.videos.forEach(video => {
- const year = video.year || currentYear;
- if (!moviesByYear[year]) {
- moviesByYear[year] = [];
- }
- moviesByYear[year].push(video);
- });
-
- // Sort years descending (newest first)
- const years = Object.keys(moviesByYear).sort((a, b) => b - a);
-
- // Create slider sections for each year
- years.forEach(year => {
- const movies = moviesByYear[year];
- if (movies.length > 0) {
- const yearLabel = year == currentYear ? `${year} New Releases` :
- year == currentYear - 1 ? `${year} Hits` :
- `${year} Movies`;
- const section = createSliderSection(yearLabel, movies);
- elements.videoGrid.appendChild(section);
- }
- });
-
- showLoading(false);
-
- } catch (error) {
- console.error('Error loading movies:', error);
- showLoading(false);
- if (elements.emptyState) elements.emptyState.style.display = 'flex';
- }
-}
-
-
-/**
- * Render demo content when API is not available
- */
-function renderDemoContent() {
- // Using CORS-friendly sample videos that work in browsers
- const SAMPLE_VIDEO = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; // Big Buck Bunny HLS
- const SAMPLE_MP4 = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';
-
- const demoVideos = [
- {
- id: 1,
- title: 'Venom: The Last Dance',
- thumbnail: 'https://image.tmdb.org/t/p/w500/aosm8NMQ3UyoBVpSxyimorCQykC.jpg',
- backdrop: 'https://image.tmdb.org/t/p/original/3V4kLQg0kSqPLctI5ziYWabAZYF.jpg',
- duration: 7200,
- resolution: '4K',
- category: 'movies',
- year: 2024,
- description: 'Eddie và Venom đang chạy trốn. Bị cả hai hai thế giới truy đuổi, hỠbuộc phải đưa ra quyết định khốc liệt...',
- }
- ];
-
- // Fuzzy match title
- const isBanner = bannerCategories.some(cat => title.includes(cat));
-
- // Attempt to find a video with a real backdrop first (landscape)
- // to avoid stretching portrait thumbnails
- const bannerVideo = videos.find(v => v.backdrop && v.backdrop !== v.thumbnail) || videos[0] || {};
- const backdrop = bannerVideo.backdrop || bannerVideo.thumbnail || '';
-
- // If we are using a thumbnail (likely portrait), apply blur
- const isPortrait = backdrop === bannerVideo.thumbnail;
- const bgStyle = isPortrait ? `background-image: url('${backdrop}'); filter: blur(20px) brightness(0.7); transform: scale(1.2);` : `background-image: url('${backdrop}');`;
-
- if (isBanner && backdrop) {
- // Create Banner Header with separate BG for zoom effects
- const bannerHeader = document.createElement('div');
- bannerHeader.className = 'section-banner group';
-
- bannerHeader.innerHTML = `
-
-
-
-
${title}
- Explore Collection ›
-
- `;
- section.appendChild(bannerHeader);
- } else {
- // Standard Header
- const header = document.createElement('h2');
- header.className = 'section-title-apple';
- header.textContent = title;
- section.appendChild(header);
- }
-
- // Split videos into chunks (Rows)
- const rowSize = 21;
-
- const createRow = (rowVideos) => {
- const container = document.createElement('div');
- container.className = 'slider-container';
- // Add vertical spacing between rows
- container.style.marginBottom = '1.5rem';
-
- container.innerHTML = `
-
-
-
-
-
- `;
-
- const track = container.querySelector('.slider-track');
- rowVideos.forEach(video => {
- const card = createVideoCard(video, handleVideoPlay, handleShowInfo);
- track.appendChild(card);
- });
-
- // Slider Logic
- const btnLeft = container.querySelector('.slider-btn--left');
- const btnRight = container.querySelector('.slider-btn--right');
-
- btnRight.addEventListener('click', () => {
- track.scrollBy({ left: window.innerWidth * 0.7, behavior: 'smooth' });
- });
-
- btnLeft.addEventListener('click', () => {
- track.scrollBy({ left: -window.innerWidth * 0.7, behavior: 'smooth' });
- });
-
- return container;
- };
-
- // Create rows
- for (let i = 0; i < videos.length; i += rowSize) {
- const chunk = videos.slice(i, i + rowSize);
- // Avoid creating a tiny final row if it has very few items compared to rowSize,
- // unless it's the only row.
- if (i > 0 && chunk.length < 5) break;
-
- section.appendChild(createRow(chunk));
- }
-
- return section;
- function setupInfiniteScrollTrigger() {
- // If no more content, hide sentinel and don't set up observer
- if (!state.hasMore) {
- if (scrollSentinel) {
- scrollSentinel.classList.remove('loading');
- scrollSentinel.style.display = 'none';
- }
- if (scrollObserver) scrollObserver.disconnect();
- return;
- }
-
- if (scrollObserver) scrollObserver.disconnect();
-
- // Remove any existing sentinels first to prevent duplicates
- document.querySelectorAll('.scroll-sentinel').forEach(el => el.remove());
- scrollSentinel = null;
-
- const options = {
- root: null,
- rootMargin: '50px', // Reduced from 200px to prevent early triggering
- threshold: 0.0 // Trigger when any part is visible
- };
-
- scrollObserver = new IntersectionObserver((entries) => {
- entries.forEach(entry => {
- // Debounce: require at least 1.5 seconds between triggers
- const now = Date.now();
- if (now - lastScrollTrigger < 1500) {
- return;
- }
- if (entry.isIntersecting && !state.isLoading && state.hasMore) {
- lastScrollTrigger = now;
- // Show loading state on sentinel
- if (scrollSentinel) scrollSentinel.classList.add('loading');
- loadVideos(state.currentCategory);
- }
- });
- }, options);
-
- // Create single sentinel element
- scrollSentinel = document.createElement('div');
- scrollSentinel.className = 'scroll-sentinel';
- scrollSentinel.id = 'scrollSentinel';
-
- // Place sentinel at the proper location - after infinite container or at end of videoGrid
- const infiniteContainer = document.getElementById('infinite-scroll-container');
- if (infiniteContainer) {
- // Insert after the infinite container for proper positioning
- infiniteContainer.parentNode.insertBefore(scrollSentinel, infiniteContainer.nextSibling);
- } else {
- elements.videoGrid.appendChild(scrollSentinel);
- }
-
- scrollObserver.observe(scrollSentinel);
- }
-
- function handleShowInfo(video) {
-
- // Smart Recommendations: Filter by category/genre
- let recommendations = state.videos.filter(v =>
- v.id !== video.id &&
- (v.category === video.category || v.resolution === video.resolution)
- );
-
- // Fallback if not enough matches
- if (recommendations.length < 6) {
- const remaining = state.videos.filter(v => v.id !== video.id && !recommendations.includes(v));
- recommendations = [...recommendations, ...remaining];
- }
-
- // Shuffle and slice
- recommendations = recommendations.sort(() => Math.random() - 0.5).slice(0, 6);
-
- const modal = createInfoModal(video, (modalEl) => {
- modalEl.classList.remove('active');
- setTimeout(() => modalEl.remove(), 400);
- }, handleVideoPlay, recommendations);
-
- }
-}
-
-/**
- * Render hero section with featured content
- */
-
-
-/**
- * Handle video play action - Navigate to dedicated watch page
- * @param {Object} video - Video object
- */
-function handleVideoPlay(video) {
- // Store video data in sessionStorage for the watch page
- sessionStorage.setItem('currentVideo', JSON.stringify(video));
-
- // Store all videos for recommendations
- sessionStorage.setItem('allVideos', JSON.stringify(state.videos));
-
- // Navigate directly to the watch page (no pushState needed for full page navigation)
- navigateToWatch(video);
-}
-
-function navigateToWatch(video) {
- window.location.href = `/watch.html?slug=${video.slug}`;
-}
-
-
-/**
- * Load specific episode with server
- */
-async function loadEpisode(video, episode, server) {
- try {
- let streamUrl = null;
- let poster = video.thumbnail;
-
- // Check if this is a PhimMoiChill movie
- const isPhimMoiChill = video.source_url && (
- video.source_url.includes('royalcanalbikehire') ||
- video.source_url.includes('phimmoichill') ||
- video.source_url.includes('/phim/') ||
- video.slug
- );
-
- if (isPhimMoiChill) {
- showToast('Loading stream...', 'info');
- try {
- const streamData = await api.getRophimStreamByUrl(video.source_url, video.slug, episode, server);
- if (streamData && streamData.stream_url) {
- streamUrl = streamData.stream_url;
- }
- } catch (phimmoiError) {
- console.warn('PhimMoiChill stream extraction failed:', phimmoiError.message);
- }
- }
-
- // Fallback: try yt-dlp extraction
- if (!streamUrl && video.source_url) {
- try {
- const extraction = await api.extractVideo(video.source_url);
- if (extraction && extraction.stream_url) {
- streamUrl = extraction.stream_url;
- poster = extraction.thumbnail || poster;
- }
- } catch (extractError) {
- console.warn('Extraction failed:', extractError.message);
- }
- }
-
- // Final fallback: use source_url directly
- if (!streamUrl && video.source_url) {
- if (video.source_url.match(/\.(mp4|m3u8|webm)(\?|$)/i)) {
- streamUrl = video.source_url;
- }
- }
-
- if (streamUrl) {
- const isEmbedUrl = streamUrl.includes('goatembed') ||
- streamUrl.includes('/embed/') ||
- streamUrl.includes('player.') ||
- (streamUrl.includes('embed') && !streamUrl.match(/\.(mp4|m3u8|webm)/i));
-
- if (isEmbedUrl) {
- elements.playerContainer.innerHTML = `
-
-
-
- `;
- } else {
- const art = initPlayer(elements.playerContainer, {
- url: streamUrl,
- poster: poster,
- title: video.title,
- autoplay: true
- });
-
- // Push state for back navigation
- if (!window.history.state?.playerOpen) {
- window.history.pushState({ playerOpen: true }, '', window.location.href);
- }
-
- if (art && window.historyService) {
- art.on('video:timeupdate', () => {
- const currentTime = art.currentTime;
- const duration = art.duration;
- if (currentTime > 0 && duration > 0 && Math.floor(currentTime) % 5 === 0) {
- window.historyService.addToHistory(video, {
- currentTime,
- duration,
- percentage: (currentTime / duration) * 100,
- episode: 1 // Default to 1 for modal player
- });
- }
- });
- }
- }
- showToast('Playing...', 'success');
- } else {
- throw new Error('Không tìm thấy nguồn phát phim');
- }
-
- } catch (error) {
- console.error('Video playback failed:', error);
- showToast(`Lá»—i: ${error.message}`, 'error');
- elements.playerContainer.innerHTML = `
-
-
-
Cannot load video
-
${video.title}
-
-
- `;
- }
-}
-
-
-/**
- * Close player modal
- * @param {boolean} shouldUpdateHistory - Whether to update history (defaults to true)
- */
-function closePlayerModal(shouldUpdateHistory = true) {
- if (elements.playerModal) {
- elements.playerModal.classList.add('hidden');
- elements.playerModal.classList.remove('active');
- elements.playerModal.style.display = 'none';
-
- // Destroy player
- destroyPlayer();
- }
-
- // If we're closing and the state still thinks it's open, and we didn't come from popstate
- if (shouldUpdateHistory && window.history.state?.playerOpen) {
- // Handled via history.back() usually
- }
- elements.playerContainer.innerHTML = '';
- state.currentVideo = null;
-}
-/**
- * Close add video modal
- */
-function closeAddModal() {
- elements.addVideoModal.classList.remove('active');
- elements.addVideoForm.reset();
-}
-
-/**
- * Handle add video form submission
- * @param {Event} e - Form submit event
- */
-async function handleAddVideo(e) {
- e.preventDefault();
-
- const url = document.getElementById('videoUrl').value;
- const title = document.getElementById('videoTitle').value;
- const category = document.getElementById('videoCategory').value;
-
- try {
- showToast('Extracting video info...', 'info');
-
- // First extract to get metadata
- const extraction = await api.extractVideo(url);
-
- // Add to library
- await api.addVideo({
- title: title || extraction.title,
- source_url: url,
- thumbnail: extraction.thumbnail,
- category: category || null
- });
-
- showToast('Video added successfully!', 'success');
- closeAddModal();
-
- // Reload videos
- await loadVideos(state.currentCategory);
-
- } catch (error) {
- console.error('Failed to add video:', error);
- showToast(`Failed to add video: ${error.message}`, 'error');
- }
-}
-
-/**
- * Set active category tab
- * @param {string} category - Category to activate
- */
-function setActiveCategory(category) {
- state.currentCategory = category;
-
- elements.categories.querySelectorAll('.category').forEach(btn => {
- btn.classList.toggle('category--active', btn.dataset.category === category);
- });
-}
-
-/**
- * Show/hide loading indicator
- * @param {boolean} show - Whether to show loading
- */
-function showLoading(show) {
- if (elements.loading) {
- elements.loading.style.display = show ? 'flex' : 'none';
- }
- // Support both old videoGrid and new mainContent layouts
- if (elements.videoGrid) {
- elements.videoGrid.style.display = show ? 'none' : 'block';
- }
-}
-
-/**
- * Render organized category view based on view type
- * Netflix-style: Multiple horizontal slider sections per view
- * @param {string} viewType - 'home', 'series', 'movies', or 'cinema'
- */
-async function renderCategoryView(viewType) {
- // Cleanup History Tabs if they exist
- const historyTabs = document.querySelector('.view-tabs');
- if (historyTabs) historyTabs.remove();
-
- if (elements.mainHeader) elements.mainHeader.style.display = '';
- showLoading(true);
- elements.videoGrid.innerHTML = '';
- elements.videoGrid.className = 'space-y-12';
-
- // Section configurations per view type (2 rows per category)
- const sectionConfigs = {
- home: [
- { title: 'Continue Watching', type: 'history', limit: 12, cardType: 'landscape' },
- { title: 'Cinema Releases', category: 'phim-chieu-rap', limit: 12, isHeroSource: true },
- { title: 'Top Rated', category: 'phim-le', sort: 'rating', limit: 12 },
- { title: 'Action & Adventure', category: 'hanh-dong', limit: 12 },
- { title: 'Animation', category: 'hoat-hinh', limit: 12 },
- { title: 'Korean Hits', category: 'han-quoc', limit: 12 },
- { title: 'Horror & Thriller', category: 'kinh-di', limit: 12 },
- { title: 'Romance', category: 'tinh-cam', limit: 12 },
- ],
- series: [
- { title: 'Popular TV Shows', category: 'phim-bo', limit: 12, isHeroSource: true },
- { title: 'Korean Dramas', category: 'korean', limit: 12 },
- { title: 'Chinese Dramas', category: 'china', limit: 12 },
- { title: 'Anime Series', category: 'hoat-hinh', limit: 12 },
- { title: 'Documentaries', category: 'tai-lieu', limit: 12 },
- ],
- movies: [
- { title: 'Blockbuster Movies', category: 'phim-le', sort: 'year', limit: 12, isHeroSource: true },
- { title: 'Action & Adventure', category: 'action', limit: 12 },
- { title: 'Comedy Films', category: 'comedy', limit: 12 },
- { title: 'Cinema Releases', category: 'phim-chieu-rap', limit: 12 },
- { title: 'Horror Movies', category: 'kinh-di', limit: 12 },
- { title: 'Sci-Fi & Fantasy', category: 'vien-tuong', limit: 12 },
- ],
- cinema: [
- { title: 'Now Showing', category: 'phim-chieu-rap', limit: 12, isHeroSource: true },
- { title: 'New Releases', category: 'phim-le', sort: 'year', limit: 12 },
- { title: 'Top Rated', category: 'phim-le', sort: 'rating', limit: 12 },
- { title: 'Action Blockbusters', category: 'action', limit: 12 },
- { title: 'Animated Features', category: 'hoat-hinh', limit: 12 },
- ]
- };
-
- const sections = sectionConfigs[viewType] || sectionConfigs.home;
-
- // DISABLED: Session cache restoration breaks event listeners
- // Event handlers are set up via JavaScript and lost when HTML is restored
- // TODO: Implement event delegation to fix this properly
- /*
- if (viewType === 'home' || viewType === 'cinema') {
- const cachedHTML = sessionStorage.getItem(`view_cache_${viewType}`);
- if (cachedHTML) {
- elements.videoGrid.innerHTML = cachedHTML;
- showLoading(false);
- if (elements.heroContainer) elements.heroContainer.style.display = '';
- if (elements.videoGrid.children.length > 0) return;
- }
- }
- */
-
- // Lazy loading configuration
- const EAGER_LOAD_COUNT = 3; // Load first 3 sections immediately
-
- try {
- let firstAvailableMovies = null;
-
- // Render eager sections immediately
- for (let i = 0; i < Math.min(EAGER_LOAD_COUNT, sections.length); i++) {
- const sectionConfig = sections[i];
- const movies = await fetchSectionMovies(sectionConfig);
- if (movies && movies.length > 0) {
- if (!firstAvailableMovies) {
- firstAvailableMovies = movies;
- }
-
- // Set featured video for hero banner from first valid section
- if (sectionConfig.isHeroSource && (!state.heroMovies || state.heroMovies.length === 0) && movies.length > 0) {
- state.heroMovies = movies.slice(0, 10);
- state.featuredVideo = movies[0];
- state.videos = movies;
- state.currentHeroIndex = 0;
- renderHero(state.heroMovies[0]);
- startHeroCarousel();
- }
-
- const sliderSection = createSliderSection(sectionConfig.title, movies, sectionConfig.cardType || 'poster');
- elements.videoGrid.appendChild(sliderSection);
- }
- }
-
- // Cache the eager sections
- if (viewType === 'home' || viewType === 'cinema') {
- sessionStorage.setItem(`view_cache_${viewType}`, elements.videoGrid.innerHTML);
- }
-
- // Create lazy-load placeholders for remaining sections
- const lazyObserver = new IntersectionObserver(async (entries, observer) => {
- for (const entry of entries) {
- if (entry.isIntersecting) {
- const placeholder = entry.target;
- const configIndex = parseInt(placeholder.dataset.configIndex);
- const sectionConfig = sections[configIndex];
-
- observer.unobserve(placeholder);
-
- // Show loading indicator
- placeholder.innerHTML = '';
-
- const movies = await fetchSectionMovies(sectionConfig);
- if (movies && movies.length > 0) {
- const sliderSection = createSliderSection(sectionConfig.title, movies, sectionConfig.cardType || 'poster');
- placeholder.replaceWith(sliderSection);
-
- // Update cache as we load more
- if (viewType === 'home' || viewType === 'cinema') {
- sessionStorage.setItem(`view_cache_${viewType}`, elements.videoGrid.innerHTML);
- }
- } else {
- placeholder.remove();
- }
- }
- }
- }, { rootMargin: '800px' });
-
- // Add placeholders for lazy sections
- for (let i = EAGER_LOAD_COUNT; i < sections.length; i++) {
- const placeholder = document.createElement('div');
- placeholder.className = 'lazy-section-placeholder h-32 mb-12';
- placeholder.dataset.configIndex = i;
- placeholder.innerHTML = `${sections[i].title}
`;
- elements.videoGrid.appendChild(placeholder);
- lazyObserver.observe(placeholder);
- }
-
- // Fallback: If hero is still empty, use first available content
-
- if (!state.featuredVideo) {
- if (firstAvailableMovies && firstAvailableMovies.length > 0) {
- state.featuredVideo = firstAvailableMovies[0];
- state.videos = firstAvailableMovies;
- renderHero();
- } else {
- // Absolute final fallback: Demo content to prevent broken UI
- try {
- const demo = getDemoContent();
- if (demo && demo.length > 0) {
- state.featuredVideo = demo[0];
- state.videos = demo;
- renderHero();
- }
- } catch (e) { console.warn('Demo content fallback failed', e); }
- }
- }
-
- // If no sections were rendered, show a message
- if (elements.videoGrid.children.length === 0) {
- elements.videoGrid.innerHTML = `
-
-
movie
-
No content available for this category
-
- `;
- }
- } catch (error) {
- console.error('Error rendering category view:', error);
- showToast('Connection failed: ' + error.message, 'error');
- elements.videoGrid.innerHTML = `
-
-
error
-
Failed to load content. Please try again.
-
- `;
- }
-
- showLoading(false);
-}
-
-/**
- * Fetch movies for a specific section configuration
- * @param {Object} config - Section configuration
- * @returns {Array} Array of movies
- */
-async function fetchSectionMovies(config) {
- try {
- // Handle history section (Continue Watching)
- if (config.type === 'history') {
- if (window.historyService) {
- const history = window.historyService.getHistory();
- return history.slice(0, config.limit).map(m => ({
- id: m.slug || m.id,
- title: m.title,
- thumbnail: m.thumbnail || m.poster_url,
- slug: m.slug,
- year: m.year,
- quality: m.quality || 'HD',
- view_progress: m.view_progress || 0 // Ensure progress
- }));
- }
- return [];
- }
-
- // Build Base API request parameters
- const baseParams = {
- category: config.category || null,
- limit: config.limit || 40,
- sort: config.sort || 'year'
- };
-
- if (config.country) baseParams.country = config.country;
- if (config.genre) baseParams.genre = config.genre;
-
- // Strategy: Aggressive Fetching (Pages 1-8)
- // Some categories with specific sorts (like year) might have broken pagination or limited data.
- // We fetch many pages to maximize chance of filling the grid.
- const fetchPages = async (params) => {
- const promises = [1, 2, 3, 4, 5, 6, 7, 8].map(page =>
- api.getRophimCatalog({ ...params, page })
- .catch(e => ({ movies: [] }))
- );
- const res = await Promise.all(promises);
- return res.flatMap(r => r.movies || []);
- };
-
- let rawMovies = await fetchPages(baseParams);
-
- // Fallback Strategy: If specific sort yielded too few results (< 20),
- // try fetching with default sort ('modified') to fill the grid.
- if (rawMovies.length < 20 && config.sort && config.sort !== 'modified') {
- const fallbackMovies = await fetchPages({ ...baseParams, sort: 'modified' });
- rawMovies = [...rawMovies, ...fallbackMovies];
- }
-
- // Deduplicate and Format
- const allMovies = [];
- const seenIds = new Set();
-
- for (const m of rawMovies) {
- if (!m) continue;
- const id = m.slug || m.id;
- if (!seenIds.has(id)) {
- seenIds.add(id);
- allMovies.push({
- id: m.id || m.slug,
- title: m.title,
- thumbnail: m.thumbnail,
- poster_url: m.poster_url || m.thumbnail,
- backdrop: m.backdrop || m.poster_url || m.thumbnail,
- slug: m.slug,
- year: m.year,
- quality: m.quality || 'HD',
- rating: m.rating,
- category: m.category
- });
- }
- }
-
- // Return up to limit (ensure we don't return too many if we over-fetched)
- // But also return enough to fill 6 rows if possible!
- const limit = Math.max(config.limit || 40, 48);
- return allMovies.slice(0, limit);
- } catch (error) {
- console.error(`Error fetching section "${config.title}":`, error);
- return [];
- }
-}
-
-// Initialize app when DOM is ready
-if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', init);
-} else {
- init();
-}
-/**
- * Get high-fidelity demo content for Netflix 2025 layout
- */
-/**
- * Get high-fidelity demo content for Netflix 2025 layout
- */
-/**
- * Get high-fidelity demo content for Netflix 2025 layout
- */
-/**
- * Get high-fidelity demo content for Netflix 2025 layout
- */
-function getDemoContent() {
- const SAMPLE_MP4 = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';
-
- // Unsplash Thematic Placeholders for Offline Mode
- const IMAGES = {
- VENOM: 'https://image.tmdb.org/t/p/w500/aosm8NMQ3UyoBVpSxyimorCQykC.jpg', // TMDB Verified
- SQUID: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=800&auto=format&fit=crop', // Red/Triangles
- ARCANE: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=800&auto=format&fit=crop', // Neon/Cyberpunk
- PENGUIN: 'https://images.unsplash.com/photo-1478720568477-152d9b164e63?w=800&auto=format&fit=crop', // Rainy City
- GLADIATOR: 'https://images.unsplash.com/photo-1565060416-522204c35613?w=800&auto=format&fit=crop', // Colosseum
- MOANA: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800&auto=format&fit=crop', // Ocean
- WICKED: 'https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=800&auto=format&fit=crop', // Green/Magic
- DBZ: 'https://images.unsplash.com/photo-1578632767115-351597cf2477?w=800&auto=format&fit=crop' // Anime/Fire
- };
-
- return [
- {
- id: 'd1',
- title: 'Venom: The Last Dance',
- thumbnail: IMAGES.VENOM,
- backdrop: 'https://image.tmdb.org/t/p/original/3V4kLQg0kSqPLctI5ziYWabAZYF.jpg',
- preview_url: SAMPLE_MP4,
- duration: 7200,
- resolution: '4K',
- category: 'action',
- year: 2024,
- matchScore: 98,
- director: 'Kelly Marcel',
- country: 'USA',
- cast: ['Tom Hardy', 'Chiwetel Ejiofor', 'Juno Temple'],
- description: 'Eddie and Venom are on the run. Hunted by both of their worlds and with the net closing in, the duo are forced into a devastating decision.',
- episodes: []
- },
- {
- id: 'd2',
- title: 'Squid Game Season 2',
- thumbnail: IMAGES.SQUID,
- backdrop: IMAGES.SQUID,
- preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
- duration: 3600,
- resolution: 'HD',
- category: 'series',
- year: 2024,
- matchScore: 99,
- director: 'Hwang Dong-hyuk',
- country: 'Korea',
- cast: ['Lee Jung-jae', 'Lee Byung-hun', 'Wi Ha-jun'],
- description: 'Gi-hun returns to the death games after three years with a new resolution: to find the people behind and to put an end to the sport.',
- episodes: [
- { number: 1, title: 'Red Light, Green Light', url: SAMPLE_MP4 },
- { number: 2, title: 'The Man with the Umbrella', url: SAMPLE_MP4 },
- { number: 3, title: 'Stick to the Team', url: SAMPLE_MP4 }
- ]
- },
- {
- id: 'd3',
- title: 'Arcane Season 2',
- thumbnail: IMAGES.ARCANE,
- backdrop: IMAGES.ARCANE, // Use high-res version normally
- preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
- duration: 2400,
- resolution: '4K',
- category: 'anime',
- year: 2024,
- matchScore: 97,
- director: 'Christian Linke',
- country: 'USA, France',
- cast: ['Hailee Steinfeld', 'Ella Purnell', 'Katie Leung'],
- description: 'As conflict between Piltover and Zaun reaches a boiling point, Jinx and Vi must decide what kind of future they are fighting for.',
- episodes: [
- { number: 1, title: 'Heavy Is the Crown', url: SAMPLE_MP4 },
- { number: 2, title: 'Watch It All Burn', url: SAMPLE_MP4 },
- { number: 3, title: 'Finally Got It Right', url: SAMPLE_MP4 }
- ]
- },
- {
- id: 'd4',
- title: 'The Penguin',
- thumbnail: IMAGES.PENGUIN,
- backdrop: IMAGES.PENGUIN,
- preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4',
- duration: 3600,
- resolution: 'HD',
- category: 'series',
- year: 2024,
- matchScore: 95,
- director: 'Craig Zobel',
- country: 'USA',
- cast: ['Colin Farrell', 'Cristin Milioti', 'Rhenzy Feliz'],
- description: 'Following the events of The Batman, Oz Cobb makes a play for power in the underworld of Gotham City.',
- episodes: []
- },
- {
- id: 'd5',
- title: 'Gladiator II',
- thumbnail: IMAGES.GLADIATOR,
- backdrop: IMAGES.GLADIATOR,
- preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4',
- duration: 8400,
- resolution: '4K',
- category: 'action',
- year: 2024,
- matchScore: 96,
- director: 'Ridley Scott',
- country: 'USA, UK',
- cast: ['Paul Mescal', 'Pedro Pascal', 'Denzel Washington'],
- description: 'Years after witnessing the death of the revered hero Maximus at the hands of his uncle, Lucius is forced to enter the Colosseum.',
- episodes: []
- },
- {
- id: 'd6',
- title: 'Moana 2',
- thumbnail: IMAGES.MOANA,
- backdrop: IMAGES.MOANA,
- preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4',
- duration: 6000,
- resolution: 'HD',
- category: 'theater',
- year: 2024,
- matchScore: 94,
- director: 'David G. Derrick Jr.',
- country: 'USA',
- cast: ['Auliʻi Cravalho', 'Dwayne Johnson', 'Alan Tudyk'],
- description: 'After receiving an unexpected call from her wayfinding ancestors, Moana must journey to the far seas of Oceania.',
- episodes: []
- },
- {
- id: 'd7',
- title: 'Wicked',
- thumbnail: IMAGES.WICKED,
- backdrop: IMAGES.WICKED,
- preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4',
- duration: 9000,
- resolution: '4K',
- category: 'theater',
- year: 2024,
- matchScore: 93,
- director: 'Jon M. Chu',
- country: 'USA',
- cast: ['Cynthia Erivo', 'Ariana Grande', 'Jeff Goldblum'],
- description: 'Elphaba, a misunderstood young woman with green skin, and Glinda, a popular blonde, forge an unlikely friendship.',
- episodes: []
- },
- {
- id: 'd8',
- title: 'Dragon Ball Daima',
- thumbnail: IMAGES.DBZ,
- backdrop: IMAGES.DBZ,
- preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4',
- duration: 1440,
- resolution: 'HD',
- category: 'anime',
- year: 2024,
- matchScore: 98,
- director: 'Yoshitaka Yashima',
- country: 'Japan',
- cast: ['Masako Nozawa', 'RyÅ Horikawa'],
- description: 'Goku and his friends are turned small due to a conspiracy. To fix things, they head off to a new world.',
- episodes: [
- { number: 1, title: 'Conspiracy', url: SAMPLE_MP4 }
- ]
- }
- ];
-}
-
-/**
- * Render Category Shortcuts (Horizontal Slider of Cards)
- */
-function renderCategoryShortcuts() {
- const shortcuts = [
- { title: 'Phim Hot', sub: '(Movies)', tag: 'Phim Hot' },
- { title: 'Phim Bá»™ Má»›i', sub: '(Series)', tag: 'Phim Bá»™ Má»›i' },
- { title: 'Hoạt Hình & Anime', sub: '(Animation)', tag: 'Hoạt Hình' },
- { title: 'Phim Việt Nam', sub: '(Local)', tag: 'Phim Việt Nam' }
- ];
-
- const section = document.createElement('section');
- section.className = 'category-shortcuts-section scrollbar-hide';
- // Style handled in CSS
-
- const track = document.createElement('div');
- track.className = 'category-shortcuts-track';
-
- shortcuts.forEach(item => {
- const card = document.createElement('div');
- card.className = 'shortcut-card';
- card.innerHTML = `
- ${item.title}
- ${item.sub}
- ›
- `;
-
- card.addEventListener('click', () => {
- // Scroll to section logic
- const titles = Array.from(document.querySelectorAll('.section-title-apple, .section-banner__title'));
- const target = titles.find(t => t.textContent.includes(item.tag));
- if (target) {
- target.closest('section')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
- } else {
- console.warn("Section not found:", item.tag);
- }
- });
- track.appendChild(card);
- });
-
- section.appendChild(track);
- return section;
-}
-/**
- * Render Profile View - Mobile first profile screen
- */
-function renderProfileView() {
- // Show standard header and hero section
- if (elements.mainHeader) elements.mainHeader.style.display = '';
- const heroContainer = document.getElementById('heroContainer');
- if (heroContainer) {
- heroContainer.style.display = '';
- renderHero();
- }
-
- // Update bottom nav active state (profile is not in nav, so none will be active)
- setMobileNavActive('profile');
-
- // Clear content
- elements.videoGrid.innerHTML = '';
- elements.videoGrid.className = 'profile-view pb-24 bg-background-light dark:bg-background-dark min-h-screen';
-
- // HTML Structure based on user example
- const profileHTML = `
-
-
-
-
Profile
-
-
-
-
- `;
-
- elements.videoGrid.innerHTML = profileHTML;
-
- // Inject history
- if (window.historyService) {
- const historyItems = window.historyService.getHistory().slice(0, 10);
- if (historyItems.length > 0) {
- const historyContainer = document.getElementById('profileHistoryContainer');
- // Re-use createSliderSection. Note: it has padding baked in from previous task.
- // We might want to ensure it looks good here.
- const slider = createSliderSection('Continue Watching', historyItems, 'landscape');
- historyContainer.appendChild(slider);
- }
- }
-}
-
-/**
- * Render Home View Wrapper
- */
-async function renderHome() {
- if (elements.mainHeader) elements.mainHeader.style.display = '';
-
- // Show hero section
- const heroContainer = document.getElementById('heroContainer');
- if (heroContainer) heroContainer.style.display = '';
-
- // Update bottom nav
- setMobileNavActive('home');
-
- // Hide footer on mobile
- if (window.innerWidth < 768) {
- document.querySelectorAll('footer').forEach(f => f.style.display = 'none');
- const searchModal = document.getElementById('searchModal');
- if (searchModal) searchModal.classList.remove('active');
- } else {
- document.querySelectorAll('footer').forEach(f => f.style.display = '');
- }
-
- await renderCategoryView('home');
-}
-
-/**
- * Render Mobile Search View
- */
-async function renderMobileSearch() {
- // Show standard header and hero section
- if (elements.mainHeader) elements.mainHeader.style.display = '';
- const heroContainer = document.getElementById('heroContainer');
- if (heroContainer) {
- heroContainer.style.display = '';
- renderHero(); // Ensure hero is populated
- }
-
- // Hide all footers on mobile
- document.querySelectorAll('footer').forEach(f => f.style.display = 'none');
-
- // Explicitly hide search modal/popup if it somehow triggered
- const searchModal = document.getElementById('searchModal');
- if (searchModal) searchModal.classList.remove('active');
-
- // Update bottom nav
- setMobileNavActive('search');
-
- // Clear content - leave room for fixed bottom nav (80px = nav height + safe area)
- elements.videoGrid.innerHTML = '';
- elements.videoGrid.className = 'mobile-search-view bg-background-light dark:bg-background-dark';
-
- // HTML Structure based on user example
- const searchHTML = `
-
-
-
-
-
- search
-
-
-
- mic
-
-
-
-
-
-
-
-
-
-
- `;
-
- elements.videoGrid.innerHTML = searchHTML;
-
- // Wire up mobile search input with proper API search
- const mobileInput = document.getElementById('mobileSearchInput');
- const resultsContainer = document.getElementById('mobileSearchResults');
- let searchTimeout = null;
-
- if (mobileInput && resultsContainer) {
- mobileInput.addEventListener('input', (e) => {
- clearTimeout(searchTimeout);
- const query = e.target.value.trim();
-
- searchTimeout = setTimeout(async () => {
- if (query.length < 2) {
- // Show default content (top searches, recommended)
- return;
- }
-
- // Show loading
- resultsContainer.innerHTML = '';
-
- try {
- const response = await api.searchRophim(query);
-
- if (response && response.movies && response.movies.length > 0) {
- resultsContainer.innerHTML = `
- Results for "${query}"
-
- `;
- const grid = resultsContainer.querySelector('.grid');
- response.movies.forEach(movie => {
- const card = document.createElement('div');
- card.className = 'relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer';
- card.innerHTML = `
-
-
- `;
- card.addEventListener('click', () => handleVideoPlay(movie));
- grid.appendChild(card);
- });
- } else {
- resultsContainer.innerHTML = `
-
-
search_off
-
No results for "${query}"
-
- `;
- }
- } catch (error) {
- console.error('Mobile search failed:', error);
- resultsContainer.innerHTML = 'Search failed. Try again.
';
- }
- }, 300);
- });
-
- // Focus input automatically
- mobileInput.focus();
- }
-
- // Cancel button clears search and restores default content
- const cancelBtn = document.getElementById('mobileSearchCancel');
- if (cancelBtn) {
- cancelBtn.addEventListener('click', () => {
- const input = document.getElementById('mobileSearchInput');
- if (input) {
- input.value = '';
- input.focus();
- }
- // Re-render the mobile search view to restore default content
- renderMobileSearch();
- });
- }
- // Populate Top Searches (Trending)
- try {
- const trending = await api.getRophimCatalog({ category: 'trending', limit: 5 });
- if (trending && trending.movies) {
- const container = document.getElementById('topSearchesList');
- trending.movies.forEach(movie => {
- const el = document.createElement('div');
- el.className = 'group flex items-center gap-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/5 cursor-pointer transition-colors';
- el.innerHTML = `
-
-
-
${movie.title}
-
${movie.year || '2024'}
-
-
- play_circle
-
- `;
- el.addEventListener('click', () => handleVideoPlay(movie));
- container.appendChild(el);
- });
- }
-
- // Populate Recommended
- const recommended = await api.getRophimCatalog({ category: 'phim-le', limit: 9 });
- if (recommended && recommended.movies) {
- const grid = document.getElementById('recommendedGrid');
- recommended.movies.forEach(movie => {
- const card = document.createElement('div');
- card.className = 'relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer';
- card.innerHTML = `
-
- `;
- card.addEventListener('click', () => handleVideoPlay(movie));
- grid.appendChild(card);
- });
- }
-
- } catch (e) {
- console.error('Failed to load mobile search content', e);
- }
-
- // Set up genre filter chip click handlers
- const filterChips = document.querySelectorAll('.search-chip');
- filterChips.forEach(chip => {
- chip.addEventListener('click', async () => {
- const genre = chip.dataset.genre;
- if (!genre) return;
-
- // Update active chip styling
- filterChips.forEach(c => {
- c.classList.remove('active', 'bg-white', 'text-black');
- c.classList.add('bg-gray-200', 'dark:bg-surface-dark');
- const p = c.querySelector('p');
- if (p) {
- p.classList.remove('font-bold');
- p.classList.add('font-medium', 'text-slate-700', 'dark:text-gray-300');
- }
- });
- chip.classList.add('active', 'bg-white', 'text-black');
- chip.classList.remove('bg-gray-200', 'dark:bg-surface-dark');
- const chipP = chip.querySelector('p');
- if (chipP) {
- chipP.classList.add('font-bold');
- chipP.classList.remove('font-medium', 'text-slate-700', 'dark:text-gray-300');
- }
-
- // Fetch and display genre content
- const resultsContainer = document.getElementById('mobileSearchResults');
- if (resultsContainer) {
- resultsContainer.innerHTML = '';
-
- try {
- const response = await api.getRophimCatalog({ category: genre, limit: 12 });
- if (response && response.movies && response.movies.length > 0) {
- const chipName = chip.querySelector('p')?.textContent || genre;
- resultsContainer.innerHTML = `
- ${chipName}
-
- `;
- const grid = resultsContainer.querySelector('.grid');
- response.movies.forEach(movie => {
- const card = document.createElement('div');
- card.className = 'relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer';
- card.innerHTML = `
-
- `;
- card.addEventListener('click', () => handleVideoPlay(movie));
- grid.appendChild(card);
- });
- } else {
- resultsContainer.innerHTML = 'No results found
';
- }
- } catch (e) {
- console.error('Genre filter error:', e);
- resultsContainer.innerHTML = 'Failed to load content
';
- }
- }
- });
- });
-}
-
-/**
- * Render Mobile My List View - Netflix-style grid layout
- */
-async function renderMobileMyList() {
- // Show standard header and hero
- if (elements.mainHeader) elements.mainHeader.style.display = '';
- const heroContainer = document.getElementById('heroContainer');
- if (heroContainer) {
- heroContainer.style.display = '';
- renderHero();
- }
-
- // Hide all footers on mobile
- document.querySelectorAll('footer').forEach(f => f.style.display = 'none');
-
- // Explicitly hide search modal/popup
- const searchModal = document.getElementById('searchModal');
- if (searchModal) searchModal.classList.remove('active');
-
- // Update nav active state
- setMobileNavActive('mylist');
-
- // Get saved items
- const items = window.historyService ? window.historyService.getFavorites() : [];
-
- elements.videoGrid.innerHTML = '';
- elements.videoGrid.className = 'mobile-mylist-view min-h-screen bg-background-dark pb-24';
-
- const mylistHTML = `
-
-
-
-
My List
-
-
-
-
-
-
-
-
-
-
- `;
-
- elements.videoGrid.innerHTML = mylistHTML;
-
- // Populate grid with saved items or fallback content
- const grid = document.getElementById('mylistGrid');
-
- if (items.length > 0) {
- items.forEach(movie => {
- const card = document.createElement('div');
- card.className = 'group relative flex flex-col gap-2 cursor-pointer';
- card.innerHTML = `
-
- `;
- card.addEventListener('click', () => handleVideoPlay(movie));
- grid.appendChild(card);
- });
- } else {
- // Load trending as placeholder
- try {
- const trending = await api.getRophimCatalog({ category: 'trending', limit: 12 });
- if (trending && trending.movies) {
- trending.movies.forEach((movie, index) => {
- const card = document.createElement('div');
- card.className = 'group relative flex flex-col gap-2 cursor-pointer';
- card.innerHTML = `
-
-
- ${index === 0 ? '
New
' : ''}
-
-
- `;
- card.addEventListener('click', () => handleVideoPlay(movie));
- grid.appendChild(card);
- });
- }
- } catch (e) {
- console.error('Failed to load my list content', e);
- }
- }
-
- // Set up My List filter chip click handlers
- const mylistChips = document.querySelectorAll('.mylist-chip');
- mylistChips.forEach(chip => {
- chip.addEventListener('click', async () => {
- const filter = chip.dataset.filter;
- const category = chip.dataset.category;
- if (!filter || !category) return;
-
- // Update active chip styling
- mylistChips.forEach(c => {
- c.classList.remove('active', 'bg-white');
- c.classList.add('bg-surface-dark');
- const p = c.querySelector('p');
- if (p) {
- p.classList.remove('font-bold', 'text-black');
- p.classList.add('font-medium', 'text-gray-200');
- }
- });
- chip.classList.add('active', 'bg-white');
- chip.classList.remove('bg-surface-dark');
- const chipP = chip.querySelector('p');
- if (chipP) {
- chipP.classList.add('font-bold', 'text-black');
- chipP.classList.remove('font-medium', 'text-gray-200');
- }
-
- // Fetch and display filtered content
- const grid = document.getElementById('mylistGrid');
- if (grid) {
- grid.innerHTML = '';
-
- try {
- const response = await api.getRophimCatalog({ category: category, limit: 12 });
- grid.innerHTML = '';
- if (response && response.movies && response.movies.length > 0) {
- response.movies.forEach((movie, index) => {
- const card = document.createElement('div');
- card.className = 'group relative flex flex-col gap-2 cursor-pointer';
- card.innerHTML = `
-
-
- ${index === 0 ? '
New
' : ''}
-
-
- `;
- card.addEventListener('click', () => handleVideoPlay(movie));
- grid.appendChild(card);
- });
- } else {
- grid.innerHTML = 'No content found
';
- }
- } catch (e) {
- console.error('Filter error:', e);
- grid.innerHTML = 'Failed to load content
';
- }
- }
- });
- });
-}
diff --git a/frontend/scripts/search.js b/frontend/scripts/search.js
deleted file mode 100755
index 1f1b632..0000000
--- a/frontend/scripts/search.js
+++ /dev/null
@@ -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 `
-
-
-
-

-
-
-
-
${movie.title}
-
- ${movie.year || ''}
- ${movie.quality ? `${movie.quality}` : ''}
-
-
-
-
-
- `;
- }).join('');
- } else {
- // No results
- searchGrid.innerHTML = `
-
-
-
No results found for "${query}"
-
- `;
- }
- } catch (error) {
- console.error('Search failed:', error);
- searchLoading.style.display = 'none';
- searchGrid.innerHTML = `
-
-
Search failed. Please try again.
-
- `;
- }
-}
-
-/**
- * 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();
-}
diff --git a/frontend/scripts/services/imageCache.js b/frontend/scripts/services/imageCache.js
deleted file mode 100755
index f22c045..0000000
--- a/frontend/scripts/services/imageCache.js
+++ /dev/null
@@ -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} - 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;
diff --git a/frontend/scripts/watch.js b/frontend/scripts/watch.js
deleted file mode 100755
index 2fac1cb..0000000
--- a/frontend/scripts/watch.js
+++ /dev/null
@@ -1,1149 +0,0 @@
-/**
- * KV-Stream Watch Page
- * Handles video playback, episode navigation, and recommendations
- */
-
-import { api } from './api.js';
-import { showToast } from './components/Toast.js';
-import { initPlayer, destroyPlayer } from './components/VideoPlayer.js';
-import { hapticLight, hapticMedium, hapticSuccess } from './haptics.js';
-import { KeyboardNavigation } from './keyboard-nav.js';
-
-// Page State
-const state = {
- video: null,
- currentEpisode: 1,
- currentServer: 0,
- recommendations: [],
- isLoading: true
-};
-
-// Expose state for debugging
-window.state = state;
-
-// DOM Elements - Resolved at runtime for robustness
-let elements = {};
-
-function initElements() {
- elements = {
- // Video player
- videoPlayer: document.getElementById('videoPlayer'),
- videoPlayerContainer: document.getElementById('videoPlayerContainer'),
- playerLoading: document.getElementById('playerLoading'),
- closePlayer: document.getElementById('closePlayer'),
-
- // Hero section (Desktop)
- heroBg: document.getElementById('heroBg'),
- movieTitle: document.getElementById('movieTitleDesktop'),
- movieMatch: document.getElementById('movieMatchDesktop'),
- movieYear: document.getElementById('movieYearDesktop'),
- movieRating: document.getElementById('movieRatingDesktop'),
- movieQuality: document.getElementById('movieQualityDesktop'),
- movieDescription: document.getElementById('movieDescriptionDesktop'),
- movieTags: document.getElementById('movieTags'),
-
- // Mobile Elements
- movieTitleMobile: document.getElementById('movieTitleMobile'),
- movieMatchMobile: document.getElementById('movieMatchMobile'),
- movieYearMobile: document.getElementById('movieYearMobile'),
- movieRatingMobile: document.getElementById('movieRatingMobile'),
- movieDuration: document.getElementById('movieDurationDesktop'), // Added this
- movieDurationMobile: document.getElementById('movieDurationMobile'),
- movieQualityMobile: document.getElementById('movieQualityMobile'),
- movieDescriptionMobile: document.getElementById('movieDescriptionMobile'),
-
- // Action buttons
- playBtn: document.getElementById('playBtnDesktop'),
- addListBtn: document.getElementById('addListBtnDesktop'),
- addListIcon: document.getElementById('addListBtnDesktop')?.querySelector('.material-symbols-outlined'),
- addListText: document.getElementById('addListBtnDesktop')?.querySelector('span:last-child'),
- playBtnMobile: document.getElementById('playBtnMobile'),
- addListBtnMobile: document.getElementById('addListBtnMobile'),
- shareBtnMobile: document.getElementById('shareBtnMobile'),
- mobilePlayBtn: document.getElementById('mobilePlayBtn'),
-
- // Navigation
- watchHeader: document.getElementById('watchHeader'),
- tabNav: document.getElementById('tabNav'),
- watchBackBtn: document.getElementById('watchBackBtn'),
-
- // Panels
- episodesPanel: document.getElementById('episodesPanel'),
- trailersPanel: document.getElementById('trailersPanel'),
- detailsPanel: document.getElementById('detailsPanel'),
-
- // Content
- seasonSelect: document.getElementById('seasonSelect'),
- seasonSelectContainer: document.getElementById('seasonSelectContainer'),
- episodeCount: document.getElementById('episodeCount'),
- episodesGrid: document.getElementById('episodesGrid'),
- episodesLoading: document.getElementById('episodesLoading'),
- castCarousel: document.getElementById('castCarousel'),
- recommendationsContainer: document.getElementById('recommendationsContainer'),
- detailsList: document.getElementById('detailsList'),
-
- // Search
- searchModal: document.getElementById('searchModal'),
- searchBtn: document.getElementById('searchBtn'),
- searchInput: document.getElementById('searchInput'),
- closeSearch: document.getElementById('closeSearch')
- };
-}
-
-
-/**
- * Initialize watch page
- */
-async function init() {
- // Initialize UI elements
- initElements();
-
- // Initialize TV Navigation
- const nav = new KeyboardNavigation();
- nav.init();
-
- // Parse URL parameters
- const params = new URLSearchParams(window.location.search);
- const videoId = params.get('id');
- const videoSlug = params.get('slug');
- const episode = parseInt(params.get('ep')) || 1;
-
- state.currentEpisode = episode;
-
- if (!videoId && !videoSlug) {
- showError('No video specified');
- return;
- }
-
- // Resolve elements once DOM is ready
- initElements();
-
- // Setup event listeners
- setupEventListeners();
-
- // Load video data
- await loadVideoData(videoId, videoSlug);
-
- // Load recommendations
- await loadRecommendations();
-}
-
-/**
- * Setup event listeners (StreamFlix Tailwind Design)
- */
-function setupEventListeners() {
- // Scroll listener for header background
- window.addEventListener('scroll', () => {
- if (elements.watchHeader) {
- if (window.scrollY > 50) {
- elements.watchHeader.style.backgroundColor = 'rgba(20,20,20,0.95)';
- } else {
- elements.watchHeader.style.backgroundColor = 'transparent';
- }
- }
- });
-
- // Back Button Logic (Robust Close)
- if (elements.watchBackBtn) {
- elements.watchBackBtn.addEventListener('click', (e) => {
- e.preventDefault();
- const playerVisible = elements.videoPlayerContainer && (elements.videoPlayerContainer.style.display !== 'none' || !elements.videoPlayerContainer.classList.contains('hidden'));
-
- if (playerVisible) {
- hapticLight();
- // Close player via history if possible
- if (window.history.state?.playerOpen) {
- window.history.back();
- } else {
- closeVideoPlayer();
- }
- } else if (document.referrer && document.referrer.includes(window.location.host)) {
- hapticLight();
- window.history.back();
- } else {
- window.location.href = '/index.html';
- }
- });
- }
-
- // New Dedicated Player Back Button
- const playerBackButton = document.getElementById('playerBackButton');
- if (playerBackButton) {
- playerBackButton.addEventListener('click', () => {
- hapticLight();
- if (window.history.state?.playerOpen) {
- window.history.back();
- } else {
- closeVideoPlayer();
- }
- });
- }
-
- // History API for Hardware Back Button / Gestures
- window.addEventListener('popstate', (event) => {
- // If the player was open but the state changed (back button pressed)
- const container = elements.videoPlayerContainer || document.getElementById('videoPlayerContainer');
- const isPlayerOpen = container && !container.classList.contains('hidden');
-
- if (isPlayerOpen && !event.state?.playerOpen) {
- closeVideoPlayer(false); // Close without pushing state again
- }
- });
-
- // Keyboard shortcuts
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- // Close video player
- if (elements.videoPlayerContainer && !elements.videoPlayerContainer.classList.contains('hidden')) {
- closeVideoPlayer();
- }
- // Close search modal
- if (elements.searchModal && !elements.searchModal.classList.contains('hidden')) {
- elements.searchModal.classList.add('hidden');
- }
- }
- });
-
- [elements.playBtn, elements.playBtnMobile, elements.mobilePlayBtn].forEach(btn => {
- if (btn) {
- btn.addEventListener('click', () => {
- if (btn) {
- hapticMedium();
- }
- if (elements.videoPlayerContainer) {
- elements.videoPlayerContainer.classList.remove('hidden');
- elements.videoPlayerContainer.style.display = 'block'; // Ensure visible
- }
- if (elements.videoPlayer) {
- elements.videoPlayer.style.display = 'block';
- }
- playCurrentEpisode();
- });
- }
- });
-
- // Close player button
- if (elements.closePlayer) {
- elements.closePlayer.addEventListener('click', () => {
- if (window.history.state?.playerOpen) {
- window.history.back();
- } else {
- closeVideoPlayer();
- }
- });
- }
-
- // Search button - open search modal
- if (elements.searchBtn) {
- elements.searchBtn.addEventListener('click', () => {
- if (elements.searchModal) {
- elements.searchModal.classList.remove('hidden');
- setTimeout(() => elements.searchInput?.focus(), 100);
- }
- });
- }
-
- // Close search button
- if (elements.closeSearch) {
- elements.closeSearch.addEventListener('click', () => {
- if (elements.searchModal) {
- elements.searchModal.classList.add('hidden');
- }
- });
- }
-
- // Add to List button
- [elements.addListBtn, elements.addListBtnMobile].forEach(btn => {
- if (btn) {
- btn.addEventListener('click', () => {
- if (!state.video) return;
-
- const added = window.historyService?.toggleFavorite(state.video);
- updateAddListUI(added);
-
- hapticLight();
- if (added) {
- showToast('Added to My List', 'success');
- } else {
- showToast('Removed from My List', 'info');
- }
- });
- }
- });
-
- // Share button
- if (elements.shareBtnMobile) {
- elements.shareBtnMobile.addEventListener('click', () => {
- if (navigator.share) {
- hapticLight();
- navigator.share({
- title: state.video?.title || 'StreamFlix',
- url: window.location.href
- });
- } else {
- hapticLight();
- // Fallback: Copy to clipboard
- navigator.clipboard.writeText(window.location.href);
- showToast('Link copied to clipboard', 'success');
- }
- });
- }
-
- // Tab Navigation (Tailwind design)
- if (elements.tabNav) {
- const tabs = elements.tabNav.querySelectorAll('.tab-btn');
- const panels = {
- episodes: elements.episodesPanel,
- details: elements.detailsPanel
- };
-
- tabs.forEach(tab => {
- tab.addEventListener('click', () => {
- hapticLight();
- const targetPanel = tab.dataset.tab;
-
- // Update active tab styling
- tabs.forEach(t => {
- t.classList.remove('text-white', 'font-bold', 'border-b-4', 'border-primary');
- t.classList.add('text-gray-400', 'font-medium');
- });
- tab.classList.remove('text-gray-400', 'font-medium');
- tab.classList.add('text-white', 'font-bold', 'border-b-4', 'border-primary');
-
- // Show/hide panels
- Object.entries(panels).forEach(([key, panel]) => {
- if (panel) {
- if (key === targetPanel) {
- panel.classList.remove('hidden');
- } else {
- panel.classList.add('hidden');
- }
- }
- });
- });
- });
- }
- // Mobile Bottom Navigation Handlers
- const mobileNavButtons = document.querySelectorAll('#mobileBottomNav .nav-item');
- mobileNavButtons.forEach(btn => {
- btn.addEventListener('click', (e) => {
- e.preventDefault();
- const view = btn.dataset.view;
- if (view) {
- hapticLight();
- // Redirect to home with view parameter
- window.location.href = `/index.html?view=${view}`;
- }
- });
- });
-}
-
-
-/**
- * Close Video Player (Robust Cleanup)
- * @param {boolean} shouldUpdateHistory - Whether to update history (defaults to true)
- */
-function closeVideoPlayer(shouldUpdateHistory = true) {
- // Re-resolve just in case
- const container = elements.videoPlayerContainer || document.getElementById('videoPlayerContainer');
- const player = elements.videoPlayer || document.getElementById('videoPlayer');
- const loader = elements.playerLoading || document.getElementById('playerLoading');
-
- if (container) {
- container.classList.add('hidden');
- container.style.display = 'none'; // Forced hide
- }
-
- // Destroy ArtPlayer instance
- destroyPlayer();
-
- if (player) {
- player.innerHTML = '';
- player.style.display = 'none';
- }
-
- if (loader) {
- loader.style.display = 'none';
- }
-
- // If we're closing and the state still thinks it's open, and we didn't come from popstate
- if (shouldUpdateHistory && window.history.state?.playerOpen) {
- // We handle this via history.back() usually, but if called directly:
- }
-}
-
-/**
- * Update Add to List UI buttons
- */
-function updateAddListUI(isAdded) {
- const icon = isAdded ? 'check' : 'add';
- const text = isAdded ? 'In List' : 'My List';
-
- // Update Desktop
- if (elements.addListBtn) {
- const iconEl = elements.addListBtn.querySelector('.material-symbols-outlined');
- const textEl = elements.addListBtn.querySelector('span:last-child');
- if (iconEl) iconEl.textContent = icon;
- if (textEl) textEl.textContent = text;
- if (isAdded) elements.addListBtn.classList.add('bg-white/20');
- else elements.addListBtn.classList.remove('bg-white/20');
- }
-
- // Update Mobile
- if (elements.addListBtnMobile) {
- const iconEl = elements.addListBtnMobile.querySelector('.material-symbols-outlined');
- const textEl = elements.addListBtnMobile.querySelector('span:last-child');
- if (iconEl) iconEl.textContent = icon;
- if (textEl) textEl.textContent = text;
- if (isAdded) {
- elements.addListBtnMobile.classList.add('bg-white/10');
- elements.addListBtnMobile.classList.remove('bg-[#2b2b2b]');
- } else {
- elements.addListBtnMobile.classList.remove('bg-white/10');
- elements.addListBtnMobile.classList.add('bg-[#2b2b2b]');
- }
- }
-}
-
-/**
- * Load video data from API or stored state
- */
-async function loadVideoData(videoId, videoSlug) {
- try {
- state.isLoading = true;
-
- let video = null;
- const slug = videoSlug || videoId;
-
- // Fetch fresh movie details from API
- if (slug) {
- try {
- const movieDetails = await api.getRophimMovie(slug);
-
- // API returns flat object, not nested under 'movie'
- if (movieDetails) {
- const movie = movieDetails.movie || movieDetails; // Support both structures
- const episodes = movieDetails.episodes || [];
-
- video = {
- id: movie.slug || slug,
- slug: movie.slug || slug,
- title: movie.name || movie.title || slug,
- original_title: movie.origin_name || movie.original_title || '',
- description: movie.content || movie.description || '',
- thumbnail: movie.poster_url || movie.thumb_url || movie.thumbnail || '',
- year: movie.year,
- rating: movie.tmdb?.vote_average || movie.rating || 'N/A',
- quality: movie.quality || 'HD',
- duration: movie.time || movie.duration || '',
-
- genres: (() => {
- if (Array.isArray(movie.category)) return movie.category.map(c => c.name || c);
- if (Array.isArray(movie.genres)) return movie.genres;
- if (typeof movie.genre === 'string') return movie.genre.split(',').map(g => g.trim());
- return [];
- })(),
- country: movie.country?.[0]?.name || movie.country || '',
- country: movie.country?.[0]?.name || movie.country || '',
- cast: movie.actor || movie.cast || [],
- director: movie.director?.[0] || movie.director || '',
- source_url: `https://phimmoichill.network/phim/${slug}`,
- episodes: parseEpisodes(episodes)
- };
- }
- } catch (apiError) {
- console.warn('API fetch failed:', apiError);
- }
- }
-
- if (!video) {
- throw new Error('Video data not found');
- }
-
- state.video = video;
-
- // Save to watch history
- if (window.historyService) {
- window.historyService.addToHistory(video, {
- episode: state.currentEpisode
- });
- }
-
- // Render video info
- renderVideoInfo(video);
-
- // Update Favorite Status
- if (window.historyService) {
- updateAddListUI(window.historyService.isFavorite(video.slug));
- }
-
- // Video is ready, but wait for user interaction to play
- // await playCurrentEpisode(); // Disabled auto-play per user request
-
- } catch (error) {
- console.error('Failed to load video:', error);
- showError('Failed to load video data');
- } finally {
- state.isLoading = false;
- }
-}
-
-/**
- * Parse episodes
- */
-function parseEpisodes(episodesData) {
- if (!episodesData || !Array.isArray(episodesData) || episodesData.length === 0) {
- return [];
- }
- const server = episodesData[0];
- const serverData = server?.server_data || [];
-
- return serverData.map((ep, index) => ({
- number: index + 1,
- name: ep.name || `Episode ${index + 1}`,
- title: ep.filename || `Episode ${index + 1}`,
- slug: ep.slug || '',
- link_embed: ep.link_embed || '',
- link_m3u8: ep.link_m3u8 || ''
- }));
-}
-
-/**
- * Render video information (StreamFlix Tailwind Design)
- */
-function renderVideoInfo(video) {
- // Hero Background Image
- if (elements.heroBg) {
- const backdrop = video.backdrop || video.poster_url || video.thumb_url || video.thumbnail || '';
- if (backdrop) {
- elements.heroBg.style.backgroundImage = `url('${backdrop}')`;
- }
- }
-
- // Title
- if (elements.movieTitle) elements.movieTitle.textContent = video.title;
-
- // Meta Data
- if (elements.movieYear) elements.movieYear.textContent = video.year || '';
- if (elements.movieDuration) {
- if (video.runtime_minutes) {
- const hours = Math.floor(video.runtime_minutes / 60);
- const mins = video.runtime_minutes % 60;
- elements.movieDuration.textContent = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
- } else if (video.duration) {
- elements.movieDuration.textContent = video.duration;
- }
- }
- if (elements.movieQuality) elements.movieQuality.textContent = video.quality || 'HD';
-
- // Rating (show as PG-13 style or numeric)
- if (elements.movieRating) {
- const rating = video.rating || video.tmdb_rating;
- if (rating && rating !== 'N/A') {
- elements.movieRating.textContent = typeof rating === 'number' ? `${rating.toFixed(1)} ★` : rating;
- } else {
- elements.movieRating.textContent = 'TV-MA';
- }
- }
-
- // Match percentage (fake Netflix-style)
- if (elements.movieMatch) {
- const matchPercent = Math.floor(85 + Math.random() * 14); // 85-98%
- elements.movieMatch.textContent = `${matchPercent}% Match`;
- }
-
- // Description
- if (elements.movieDescription) {
- const description = video.tmdb_description || video.description || 'No description available.';
- // Use innerHTML to render any HTML tags provided by the API (e.g. ,
)
- elements.movieDescription.innerHTML = description;
- if (elements.movieDescriptionMobile) elements.movieDescriptionMobile.innerHTML = description;
- }
-
- // Mobile Data Population
- if (elements.movieTitleMobile) elements.movieTitleMobile.textContent = video.title;
- if (elements.movieYearMobile) elements.movieYearMobile.textContent = video.year || '';
- if (elements.movieRatingMobile) {
- const rating = video.rating || video.tmdb_rating;
- elements.movieRatingMobile.textContent = (rating && rating !== 'N/A') ? (typeof rating === 'number' ? rating.toFixed(1) : rating) : 'TV-MA';
- }
- if (elements.movieDurationMobile) elements.movieDurationMobile.textContent = elements.movieDuration ? elements.movieDuration.textContent : (video.duration || '');
- if (elements.movieQualityMobile) elements.movieQualityMobile.textContent = video.quality || 'HD';
- if (elements.movieMatchMobile && elements.movieMatch) elements.movieMatchMobile.textContent = elements.movieMatch.textContent;
-
- // Genre Tags
- if (elements.movieTags) {
- const genres = video.genres || [];
- const director = video.director;
- const country = video.country;
-
- let tagsHTML = '';
- if (genres.length > 0) {
- tagsHTML += `
Genres: ${genres.join(', ')}
`;
- }
- if (director && director !== 'Unknown') {
- tagsHTML += `Director: ${director}
`;
- }
- if (country && country !== 'Unknown') {
- tagsHTML += `Country: ${country}
`;
- }
-
- elements.movieTags.innerHTML = tagsHTML;
- }
-
- // Update page title
- document.title = `${video.title} - StreamFlix`;
-
- // Update Add to List button state
- if (window.historyService && video.slug) {
- updateAddListUI(window.historyService.isFavorite(video.slug));
- }
-
- // Render episodes
- renderEpisodes(video);
-
- // Render cast
- if (video.tmdb_cast && video.tmdb_cast.length > 0) {
- renderCast(video.tmdb_cast, true);
- } else if (video.cast && video.cast.length > 0) {
- renderCast(video.cast, false);
- }
-
- // Render additional details
- renderDetails(video);
-}
-
-/**
- * Render episodes grid (StreamFlix Tailwind Design)
- */
-function renderEpisodes(video) {
- if (!elements.episodesPanel) return;
-
- // Get episodes from the API response format
- let episodes = [];
- if (Array.isArray(video.episodes) && video.episodes.length > 0) {
- if (video.episodes[0].server_data) {
- episodes = video.episodes[0].server_data;
- } else {
- episodes = video.episodes;
- }
- }
-
- // Hide episodes section for single-episode movies
- if (episodes.length <= 1) {
- if (elements.seasonSelectContainer) elements.seasonSelectContainer.style.display = 'none';
- if (elements.episodesLoading) elements.episodesLoading.style.display = 'none';
-
- // Show "Play Movie" message instead
- if (elements.episodesGrid) {
- elements.episodesGrid.innerHTML = `
-
-
play_circle
-
-
Full Movie
-
Click Play to watch
-
-
- `;
- }
- return;
- }
-
- // Update episode count
- if (elements.episodeCount) elements.episodeCount.textContent = `${episodes.length} Episodes`;
- if (elements.episodesLoading) elements.episodesLoading.style.display = 'none';
-
- // Render episode cards
- if (elements.episodesGrid) {
- const INITIAL_LIMIT = 10;
- const totalEp = episodes.length;
- const showAll = totalEp <= (INITIAL_LIMIT + 5); // If only a few more, just show all
-
- const renderBatch = (limit) => {
- elements.episodesGrid.innerHTML = episodes.slice(0, limit).map((ep, index) => {
- const epNumber = index + 1;
- const isActive = epNumber === state.currentEpisode;
- const epName = ep.name || `Episode ${epNumber}`;
- const epTitle = ep.title || ep.filename || '';
-
- return `
-
-
${epNumber}
-
-
${epName}
- ${epTitle ? `
${epTitle}
` : ''}
-
- ${isActive ? '
play_circle' : ''}
-
- `;
- }).join('');
-
- if (limit < totalEp) {
- const seeMoreBtn = document.createElement('button');
- seeMoreBtn.className = 'w-full py-4 text-gray-400 hover:text-white font-medium flex items-center justify-center gap-2 border-t border-white/5 mt-2 transition-colors';
- seeMoreBtn.innerHTML = `
- See more episodes (${totalEp - limit} remaining)
- expand_more
- `;
- seeMoreBtn.onclick = () => renderBatch(totalEp);
- elements.episodesGrid.appendChild(seeMoreBtn);
- }
- };
-
- renderBatch(showAll ? totalEp : INITIAL_LIMIT);
- }
-}
-
-/**
- * Render additional details (About section)
- */
-function renderDetails(video) {
- if (!elements.detailsList) return;
-
- const details = [];
-
- if (video.original_title) details.push({ label: 'Original Title', value: video.original_title });
- if (video.director && video.director !== 'Unknown') details.push({ label: 'Director', value: video.director });
- if (video.country && video.country !== 'Unknown') details.push({ label: 'Country', value: video.country });
- if (video.year) details.push({ label: 'Release Year', value: video.year });
- if (video.quality) details.push({ label: 'Quality', value: video.quality });
- if (video.duration) details.push({ label: 'Duration', value: video.duration });
- if (video.genres && video.genres.length > 0) details.push({ label: 'Genres', value: video.genres.join(', ') });
-
- // Clear existing
- elements.detailsList.innerHTML = '';
-
- details.forEach(d => {
- const row = document.createElement('div');
- row.className = 'flex gap-4';
-
- const label = document.createElement('span');
- label.className = 'text-white/50 min-w-[100px] font-medium';
- label.textContent = `${d.label}:`;
-
- const value = document.createElement('span');
- value.className = 'text-white font-medium';
- value.textContent = d.value;
-
- row.appendChild(label);
- row.appendChild(value);
- elements.detailsList.appendChild(row);
- });
-}
-
-// Global scope for onclick
-window.selectEpisode = (episodeNumber) => {
- state.currentEpisode = episodeNumber;
-
- // Update URL
- const url = new URL(window.location);
- url.searchParams.set('ep', episodeNumber);
- window.history.replaceState({}, '', url);
-
- // Re-render to update active state
- renderEpisodes(state.video);
-
- // Play
- playCurrentEpisode();
-
- // Scroll to top
- window.scrollTo({ top: 0, behavior: 'smooth' });
-};
-
-/**
- * Play current episode
- */
-async function playCurrentEpisode() {
- if (!state.video) return;
-
- if (elements.playerLoading) elements.playerLoading.style.display = 'flex';
-
- try {
- let streamUrl = null;
- let poster = state.video.thumbnail;
-
- // Get episodes from the API response format (ophim format has server_data)
- let episodes = [];
- if (Array.isArray(state.video.episodes) && state.video.episodes.length > 0) {
- if (state.video.episodes[0].server_data) {
- episodes = state.video.episodes[0].server_data;
- } else {
- episodes = state.video.episodes;
- }
- }
-
- const currentEp = episodes[state.currentEpisode - 1];
-
- // Save to history
- if (window.historyService) {
- window.historyService.addToHistory(state.video, {
- episode: state.currentEpisode,
- timestamp: Date.now()
- });
- }
-
- // Try to get stream URL from episode data (ophim provides direct links)
- if (currentEp) {
- // Prefer m3u8 for native playback, fallback to embed
- if (currentEp.link_m3u8) {
- streamUrl = currentEp.link_m3u8;
- } else if (currentEp.link_embed) {
- streamUrl = currentEp.link_embed;
- }
- }
-
- // If still no stream, try getting it via the getRophimStream method
- if (!streamUrl && state.video.slug) {
- try {
- const streamData = await api.getRophimStream(state.video.slug, state.currentEpisode);
- if (streamData?.stream_url) streamUrl = streamData.stream_url;
- } catch (e) {
- console.warn('Stream API fallback also failed', e);
- }
- }
-
- if (elements.playerLoading) elements.playerLoading.style.display = 'none';
-
- if (streamUrl) {
- renderPlayer(streamUrl, poster, state.video.title);
- const epLabel = episodes.length > 1 ? `Episode ${state.currentEpisode} ` : 'Movie';
- showToast(`Playing ${epLabel} `, 'success');
- } else {
- // Show watch externally option
- const episodeStr = state.currentEpisode === 1 ? 'full' : state.currentEpisode;
- const externalUrl = `https://phimmoichill.network/xem-phim/${state.video.slug}/tap-${episodeStr}-sv-0`;
- showExternalPlayerOption(externalUrl);
- }
- } catch (error) {
- console.error(error);
- showPlaybackError(error.message);
- }
-}
-
-function showExternalPlayerOption(externalUrl) {
- elements.videoPlayer.innerHTML = `
-
-
-
It cannot load
-
This stream is currently unavailable. Please try again later or choose another source.
-
- `;
-}
-
-/**
- * Render player
- */
-function renderPlayer(streamUrl, poster, title) {
- // Check if embed (add simple check for common embed domains)
- const isEmbed = streamUrl.includes('embed') || !streamUrl.match(/\.(mp4|m3u8)$/i);
-
- if (isEmbed) {
- elements.videoPlayer.innerHTML = `
-
- `;
- } else {
- // Initialize ArtPlayer
- const art = renderArtPlayer(streamUrl, poster, title);
-
- // Push state to history for back navigation
- if (!window.history.state?.playerOpen) {
- window.history.pushState({ playerOpen: true }, '', window.location.href);
- }
- }
-}
-
-/**
- * Render ArtPlayer instance
- */
-function renderArtPlayer(streamUrl, poster, title) {
- const art = initPlayer(elements.videoPlayer, {
- url: streamUrl,
- poster: poster,
- title: title + ` - Ep ${state.currentEpisode}`,
- autoplay: true
- });
-
- // Track progress
- if (art && window.historyService) {
- art.on('video:timeupdate', () => {
- const currentTime = art.currentTime;
- const duration = art.duration;
- if (currentTime > 0 && duration > 0) {
- // Save every 5 seconds to avoid excessive writes
- if (Math.floor(currentTime) % 5 === 0) {
- window.historyService.addToHistory(state.video, {
- currentTime,
- duration,
- percentage: (currentTime / duration) * 100,
- episode: state.currentEpisode
- });
- }
- }
- });
-
- // Resume from last position if available
- const history = window.historyService.getHistory();
- const entry = history.find(item => item.slug === state.video.slug);
- if (entry && entry.progress && entry.progress.episode === state.currentEpisode) {
- if (entry.progress.currentTime > 0 && entry.progress.percentage < 95) {
- art.once('video:canplay', () => {
- art.currentTime = entry.progress.currentTime;
- });
- }
- }
- }
-}
-
-function showPlaybackError(msg) {
- elements.videoPlayer.innerHTML = `
-
-
Error loading video: ${msg}
-
-
- `;
-}
-
-/**
- * Render Cast (StreamFlix Tailwind Design - circular avatars)
- */
-function renderCast(cast, isTMDB = false) {
- if (!elements.castCarousel) return;
-
- const displayCast = cast.slice(0, 10);
-
- if (isTMDB) {
- elements.castCarousel.innerHTML = displayCast.map(person => {
- const hasPhoto = person.profile_photo && !person.profile_photo.includes('ui-avatars.com');
- const photoUrl = person.profile_photo || '';
- const searchUrl = `/?search=${encodeURIComponent(person.name)}`;
- const initials = person.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
-
- return `
-
-
- ${hasPhoto
- ? `

`
- : `
${initials}
`
- }
-
- ${person.name}
- ${person.character || 'Actor'}
-
- `;
- }).join('');
- } else {
- elements.castCarousel.innerHTML = displayCast.map(actor => {
- const searchUrl = `/?search=${encodeURIComponent(actor)}`;
- const initials = actor.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
-
- return `
-
-
- ${initials}
-
- ${actor}
- Actor
-
- `;
- }).join('');
- }
-}
-
-/**
- * Load Recommendations (StreamFlix Tailwind Design)
- */
-/**
- * Load Recommendations (Expanded: Genre, Country, Year)
- */
-async function loadRecommendations() {
- const container = elements.recommendationsContainer;
- if (!container) return;
-
- try {
- container.innerHTML = '';
-
- const video = state.video;
- if (!video) return;
-
- const currentSlug = video.slug;
- const usedSlugs = new Set([currentSlug]);
-
- // 1. Prepare Categories
- const genres = video.category ? Object.values(video.category) : (video.genres || []);
- const countries = video.country ? Object.values(video.country) : (video.countries || []);
- const year = video.year;
-
- const requests = [];
-
- // Category 1: Similar (Genre)
- if (genres.length > 0) {
- let genreSlug = '';
- // Handle both object {id: name} and string array
- if (typeof genres[0] === 'object' && genres[0].slug) {
- genreSlug = genres[0].slug;
- } else if (typeof genres[0] === 'string') {
- genreSlug = genres[0].toLowerCase()
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
- .replace(/Ä‘/g, 'd').replace(/\s+/g, '-');
- }
-
- // Adjust slug logic if needed based on API
- // For RoPhim it's often 'the-loai/'
- if (genreSlug) {
- requests.push(
- api.getRophimCatalog({ page: 1, limit: 24, category: `the-loai/${genreSlug}` })
- .then(res => ({ title: "More Like This", movies: res.movies || [] }))
- .catch(() => null)
- );
- }
- }
-
- // Category 2: Same Country
- if (countries.length > 0) {
- let countrySlug = '';
- if (typeof countries[0] === 'object' && countries[0].slug) {
- countrySlug = countries[0].slug;
- } else if (typeof countries[0] === 'string') {
- countrySlug = countries[0].toLowerCase()
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
- .replace(/Ä‘/g, 'd').replace(/\s+/g, '-');
- }
-
- if (countrySlug) {
- requests.push(
- api.getRophimCatalog({ page: 1, limit: 24, category: `quoc-gia/${countrySlug}` })
- .then(res => ({ title: `Movies from ${countries[0].name || countries[0]}`, movies: res.movies || [] }))
- .catch(() => null)
- );
- }
- }
-
- // Category 3: Same Year
- if (year) {
- requests.push(
- api.getRophimCatalog({ page: 1, limit: 24, category: `nam-phat-hanh/${year}` })
- .then(res => ({ title: `Released in ${year}`, movies: res.movies || [] }))
- .catch(() => null)
- );
- }
-
- // Execute all requests
- const results = await Promise.all(requests);
-
- container.innerHTML = ''; // Clear loading
-
- const renderedTitles = new Set();
- let hasContent = false;
-
- results.forEach(section => {
- if (!section || !section.movies || section.movies.length === 0) return;
-
- // Deduplicate Titles (Prevent multiple 'More Like This')
- if (section.title && renderedTitles.has(section.title)) return;
- if (section.title) renderedTitles.add(section.title);
-
- // Filter duplicates
- const uniqueMovies = section.movies.filter(m => !usedSlugs.has(m.slug));
- uniqueMovies.forEach(m => usedSlugs.add(m.slug));
-
- if (uniqueMovies.length === 0) return;
-
- hasContent = true;
-
- const sectionHtml = `
-
- ${section.title ? `
${section.title}
` : ''}
-
- ${uniqueMovies.map(v => createCardHtml(v)).join('')}
-
-
- `;
- container.insertAdjacentHTML('beforeend', sectionHtml);
- });
-
- if (!hasContent) {
- container.innerHTML = 'No specific recommendations found.
';
- }
-
- } catch (error) {
- console.error('Failed to load recommendations:', error);
- container.innerHTML = 'Failed to load recommendations
';
- }
-}
-
-/**
- * Helper to create card HTML (Smaller for Recommendations)
- */
-function createCardHtml(v) {
- const poster = v.poster_url || v.thumbnail || v.thumb_url || '';
- const title = v.name || v.title || 'Untitled';
- const year = v.year || '';
- const quality = v.quality || 'HD';
- const match = v.matchScore || Math.floor(Math.random() * (99 - 85 + 1) + 85);
- const tmdb = v.tmdb_rating || 0;
- const rtScore = Math.round(tmdb * 10);
- const slug = v.slug || v.id || '';
-
- // Smaller card dimensions for "More Like This"
- return `
-
-
-
-
-
-
-
-
-
- ${year == new Date().getFullYear() ? `NEW` : ''}
- ${quality.replace('FHD', 'HD')}
-
-
-
-
-
-
-
-
-
-
${title}
-
-
${match}% Match
-
-
- local_pizza ${rtScore}%
-
-
-
-
-
-
- `;
-}
-
-
-
-function showError(msg) {
- document.body.innerHTML = `
-
-
error
-
${msg}
-
Go Home
-
- `;
-}
-
-// Init
-if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', init);
-} else {
- init();
-}
diff --git a/frontend/styles/base.css b/frontend/styles/base.css
deleted file mode 100755
index 8941c69..0000000
--- a/frontend/styles/base.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/frontend/styles/components/buttons.css b/frontend/styles/components/buttons.css
deleted file mode 100755
index b2347a2..0000000
--- a/frontend/styles/components/buttons.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/frontend/styles/components/cards.css b/frontend/styles/components/cards.css
deleted file mode 100755
index 7a48cbc..0000000
--- a/frontend/styles/components/cards.css
+++ /dev/null
@@ -1,549 +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/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,
-.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);
- }
-}
-
-/* TV Mode Body Class - Enhanced for 10-foot UI */
-body.tv-mode .video-card.keyboard-focused .video-card__container,
-body.tv-mode .video-card:focus .video-card__container {
- transform: scale(1.12);
- box-shadow:
- 0 0 0 8px var(--netflix-red),
- 0 0 60px rgba(229, 9, 20, 0.7),
- var(--shadow-card-hover);
- transition: transform 0.2s ease, box-shadow 0.2s ease;
-}
-
-/* Generic focus styles for other interactive elements */
-body.tv-mode button:focus,
-body.tv-mode a:focus,
-body.tv-mode .nav-link:focus,
-body.tv-mode .tab-btn:focus,
-body.tv-mode .episode-row:focus {
- outline: 3px solid var(--netflix-red);
- outline-offset: 2px;
-}
-
-/* Force show overlay on keyboard focus (for TV users) */
-.video-card.keyboard-focused .video-card__overlay,
-.video-card:focus .video-card__overlay {
- opacity: 1;
-}
\ No newline at end of file
diff --git a/frontend/styles/components/forms.css b/frontend/styles/components/forms.css
deleted file mode 100755
index 1c224e9..0000000
--- a/frontend/styles/components/forms.css
+++ /dev/null
@@ -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);
-}
\ No newline at end of file
diff --git a/frontend/styles/components/loading.css b/frontend/styles/components/loading.css
deleted file mode 100755
index 57ccc3c..0000000
--- a/frontend/styles/components/loading.css
+++ /dev/null
@@ -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);
-}
\ No newline at end of file
diff --git a/frontend/styles/components/modals.css b/frontend/styles/components/modals.css
deleted file mode 100755
index 700204f..0000000
--- a/frontend/styles/components/modals.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/frontend/styles/grid-patch.css b/frontend/styles/grid-patch.css
deleted file mode 100755
index 5f72e98..0000000
--- a/frontend/styles/grid-patch.css
+++ /dev/null
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/frontend/styles/index.css b/frontend/styles/index.css
deleted file mode 100755
index 91c32d7..0000000
--- a/frontend/styles/index.css
+++ /dev/null
@@ -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';
\ No newline at end of file
diff --git a/frontend/styles/layout.css b/frontend/styles/layout.css
deleted file mode 100755
index e374516..0000000
--- a/frontend/styles/layout.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/frontend/styles/responsive-patch.css b/frontend/styles/responsive-patch.css
deleted file mode 100755
index 37dd52c..0000000
--- a/frontend/styles/responsive-patch.css
+++ /dev/null
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/frontend/styles/responsive.css b/frontend/styles/responsive.css
deleted file mode 100755
index e487ce8..0000000
--- a/frontend/styles/responsive.css
+++ /dev/null
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/frontend/styles/search-modal.css b/frontend/styles/search-modal.css
deleted file mode 100755
index e83571d..0000000
--- a/frontend/styles/search-modal.css
+++ /dev/null
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/frontend/styles/sections/feed.css b/frontend/styles/sections/feed.css
deleted file mode 100755
index 43728d7..0000000
--- a/frontend/styles/sections/feed.css
+++ /dev/null
@@ -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);
-}
\ No newline at end of file
diff --git a/frontend/styles/sections/hero.css b/frontend/styles/sections/hero.css
deleted file mode 100755
index 9949387..0000000
--- a/frontend/styles/sections/hero.css
+++ /dev/null
@@ -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%;
-}
\ No newline at end of file
diff --git a/frontend/styles/sections/sliders.css b/frontend/styles/sections/sliders.css
deleted file mode 100755
index bbc9ab8..0000000
--- a/frontend/styles/sections/sliders.css
+++ /dev/null
@@ -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);
-}
\ No newline at end of file
diff --git a/frontend/styles/variables.css b/frontend/styles/variables.css
deleted file mode 100755
index e75a6b4..0000000
--- a/frontend/styles/variables.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/frontend/styles/watch.css b/frontend/styles/watch.css
deleted file mode 100755
index fe968f0..0000000
--- a/frontend/styles/watch.css
+++ /dev/null
@@ -1,1060 +0,0 @@
-/* ============================================
- KV-Netflix Watch Page Styles
- Apple TV+ Inspired Design
- ============================================ */
-
-/* ============================================
- Watch Page Variables
- ============================================ */
-:root {
- --watch-bg: #000000;
- --watch-text-primary: #ffffff;
- --watch-text-secondary: rgba(255, 255, 255, 0.55);
- --watch-accent: #ffffff;
- /* Apple uses white for selection/focus */
- --watch-button-bg: #ffffff;
- --watch-button-text: #000000;
- --watch-glass-bg: rgba(255, 255, 255, 0.1);
- --watch-glass-border: rgba(255, 255, 255, 0.15);
-
- --font-primary: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
- --font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
-}
-
-/* ============================================
- Page Layout
- ============================================ */
-.watch-page {
- background: var(--watch-bg);
- color: var(--watch-text-primary);
- min-height: 100vh;
- font-family: var(--font-primary);
- overflow-x: hidden;
-}
-
-/* ============================================
- Watch Header - Minimal Design
- ============================================ */
-.watch-header {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- height: 70px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 40px;
- z-index: 1000;
- background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
- transition: background 0.4s ease, backdrop-filter 0.4s ease;
-}
-
-.watch-header.scrolled {
- background: rgba(0, 0, 0, 0.85);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
-}
-
-.watch-header__left,
-.watch-header__right {
- display: flex;
- align-items: center;
- gap: 20px;
- flex: 1;
-}
-
-.watch-header__center {
- flex: 0;
-}
-
-.watch-header__right {
- justify-content: flex-end;
-}
-
-.watch-header__back {
- display: flex;
- align-items: center;
- gap: 10px;
- color: rgba(255, 255, 255, 0.8);
- text-decoration: none;
- font-size: 15px;
- font-weight: 500;
- transition: all 0.2s ease;
- padding: 8px 12px;
- border-radius: 50px;
-}
-
-.watch-header__back:hover {
- color: #fff;
- background: rgba(255, 255, 255, 0.1);
-}
-
-.watch-header__logo {
- font-family: var(--font-display);
- font-size: 22px;
- font-weight: 600;
- color: #fff;
- text-decoration: none;
- letter-spacing: -0.5px;
- opacity: 0.9;
-}
-
-.watch-header__btn {
- width: 44px;
- height: 44px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: transparent;
- border: none;
- border-radius: 50%;
- color: rgba(255, 255, 255, 0.8);
- cursor: pointer;
- transition: all 0.2s ease;
-}
-
-.watch-header__btn:hover {
- background: rgba(255, 255, 255, 0.1);
- color: #fff;
- transform: scale(1.05);
-}
-
-/* ============================================
- Watch Main Content
- ============================================ */
-.watch-main {
- padding-bottom: 80px;
-}
-
-/* ============================================
- Video Theater Section
- ============================================ */
-.video-theater {
- position: relative;
- width: 100%;
- background: #000;
- height: 70vh;
- /* Reduced for better spacing */
- max-height: 900px;
- min-height: 500px;
-}
-
-.video-theater__container {
- position: relative;
- width: 100%;
- height: 100%;
-}
-
-.video-theater__player {
- position: relative;
- width: 100%;
- height: 100%;
- background: #000;
-}
-
-/* Ensure iframe fills container */
-.video-theater__player iframe {
- width: 100%;
- height: 100%;
- border: none;
-}
-
-/* Loading State */
-.video-theater__loading {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 20px;
- color: rgba(255, 255, 255, 0.6);
-}
-
-.loading-spinner {
- width: 50px;
- height: 50px;
- border: 3px solid rgba(255, 255, 255, 0.1);
- border-top-color: #fff;
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
-}
-
-@keyframes spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-/* Gradient Overlay - Crucial for the "Immersive" feel */
-.video-theater__gradient {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- height: 400px;
- background: linear-gradient(to bottom, transparent 0%, #000000 90%);
- pointer-events: none;
- z-index: 2;
-}
-
-/* ============================================
- Movie Details Section - Immersive Overlay
- ============================================ */
-.movie-details {
- position: relative;
- margin-top: 0;
- /* Clean separation - no overlap */
- padding-top: 40px;
- z-index: 10;
- padding-bottom: 40px;
-}
-
-.movie-details__container {
- max-width: 1400px;
- /* Wide container */
- margin: 0 auto;
- padding: 0 60px;
-}
-
-/* Info Elements */
-.info-content {
- max-width: 800px;
-}
-
-/* Dynamic Badge Container */
-.info-badges {
- display: flex;
- gap: 12px;
- margin-bottom: 16px;
- align-items: center;
-}
-
-.badge-logo {
- height: 20px;
- width: auto;
- opacity: 0.8;
-}
-
-/* Title */
-.info-title {
- font-family: var(--font-display);
- font-size: 56px;
- font-weight: 700;
- color: #fff;
- margin: 0 0 8px 0;
- letter-spacing: -1px;
- line-height: 1.1;
- text-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
-}
-
-.info-original-title {
- font-size: 18px;
- color: rgba(255, 255, 255, 0.6);
- margin: 0 0 20px 0;
- font-weight: 400;
-}
-
-/* Metadata Row */
-.info-meta {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 24px;
- font-size: 15px;
- font-weight: 500;
- color: rgba(255, 255, 255, 0.7);
-}
-
-.meta-divider {
- width: 3px;
- height: 3px;
- background-color: rgba(255, 255, 255, 0.4);
- border-radius: 50%;
-}
-
-.meta-quality {
- border: 1px solid rgba(255, 255, 255, 0.4);
- padding: 1px 4px;
- border-radius: 4px;
- font-size: 11px;
- line-height: 1.2;
-}
-
-/* Action Buttons */
-.info-actions {
- display: flex;
- gap: 16px;
- margin-bottom: 32px;
- margin-top: 32px;
-}
-
-.action-btn {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 14px 32px;
- border-radius: 40px;
- font-size: 16px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
- border: none;
- letter-spacing: -0.01em;
-}
-
-/* Primary Play Button - White Pill */
-.action-btn--primary {
- background: #ffffff;
- color: #000000;
-}
-
-.action-btn--primary:hover {
- transform: scale(1.04);
- box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
-}
-
-/* Secondary Buttons - Glass/Circle */
-.action-btn--glass {
- background: var(--glass-bg);
- color: #ffffff;
- backdrop-filter: var(--glass-blur) var(--glass-saturate);
- -webkit-backdrop-filter: var(--glass-blur) var(--glass-saturate);
- border: var(--apple-border);
- padding: 12px 24px;
- border-radius: 40px;
-}
-
-.action-btn--glass.icon-only {
- padding: 0;
- border-radius: 50%;
- width: 48px;
- height: 48px;
- min-width: 48px;
- justify-content: center;
- flex: 0 0 48px;
- /* Force circle */
-}
-
-.action-btn--glass:hover {
- background: rgba(255, 255, 255, 0.2);
- border-color: rgba(255, 255, 255, 0.3);
- transform: scale(1.06);
-}
-
-.action-btn--glass.active {
- background: #ffffff;
- color: #000000;
- box-shadow: 0 0 15px rgba(255, 255, 255, 0.4);
-}
-
-/* Description */
-.info-description {
- font-size: 17px;
- line-height: 1.6;
- color: rgba(255, 255, 255, 0.7);
- margin-bottom: 50px;
- max-width: 700px;
- text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
-}
-
-/* Genres */
-.info-genres {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
-}
-
-.genre-tag {
- font-size: 14px;
- color: rgba(255, 255, 255, 0.6);
- cursor: pointer;
- transition: color 0.2s;
-}
-
-.genre-tag:hover {
- color: #fff;
- text-decoration: underline;
-}
-
-/* ============================================
- Section Styling (Shared)
- ============================================ */
-.content-section {
- padding: 40px 0;
- opacity: 0;
- animation: fadeIn 0.8s ease forwards;
- animation-delay: 0.3s;
-}
-
-.content-container {
- max-width: 1400px;
- margin: 0 auto;
- padding: 0 60px;
-}
-
-.section-title {
- font-family: var(--font-display);
- font-size: 24px;
- font-weight: 700;
- color: #fff;
- margin: 0 0 24px 0;
- letter-spacing: -0.5px;
-}
-
-/* ============================================
- Episodes Section
- ============================================ */
-.episodes-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: 20px;
-}
-
-/* Episode Card */
-.episode-card {
- background: transparent;
- border-radius: 12px;
- overflow: hidden;
- cursor: pointer;
- transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
-}
-
-.episode-card:hover {
- transform: scale(1.02);
-}
-
-.episode-card.active .episode-thumb {
- box-shadow: 0 0 0 3px #fff;
-}
-
-.episode-thumb {
- position: relative;
- aspect-ratio: 16 / 9;
- border-radius: 12px;
- overflow: hidden;
- margin-bottom: 12px;
- background: #1a1a1a;
-}
-
-.episode-thumb img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- transition: opacity 0.3s;
-}
-
-.episode-card:hover .episode-thumb img {
- opacity: 0.8;
-}
-
-/* Modal Content */
-.modal__content {
- position: relative;
- width: 100%;
- max-width: 480px;
- margin: var(--spacing-xl);
- padding: var(--spacing-xl);
- background: var(--glass-bg);
- backdrop-filter: var(--glass-blur-deep) var(--glass-saturate);
- -webkit-backdrop-filter: var(--glass-blur-deep) var(--glass-saturate);
- border: var(--apple-border);
- border-radius: var(--radius-xl);
- box-shadow: var(--shadow-xl);
- animation: slideUp 0.3s ease;
-}
-
-/* Play Icon Overlay */
-.episode-play {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 44px;
- height: 44px;
- background: rgba(0, 0, 0, 0.6);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- transition: all 0.3s;
- backdrop-filter: blur(4px);
-}
-
-.episode-card:hover .episode-play {
- opacity: 1;
-}
-
-.episode-info h3 {
- font-size: 15px;
- font-weight: 600;
- color: #fff;
- margin: 0 0 4px 0;
-}
-
-.episode-info p {
- font-size: 13px;
- color: rgba(255, 255, 255, 0.5);
- margin: 0;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-/* ============================================
- Carousel (Recommendations & Cast)
- ============================================ */
-.carousel-wrapper {
- position: relative;
- margin: 0 -60px;
- /* Bleed out */
- padding: 0 60px;
- /* Padding in */
- overflow-x: auto;
- display: flex;
- gap: 20px;
- padding-bottom: 30px;
- /* Space for scrollbar/shadow */
- scrollbar-width: none;
- -ms-overflow-style: none;
-}
-
-.carousel-wrapper::-webkit-scrollbar {
- display: none;
-}
-
-/* Recommended Video Card */
-.rec-card {
- flex: 0 0 180px;
- /* Smaller cards for more movies */
- cursor: pointer;
- transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
-}
-
-.rec-card:hover {
- transform: scale(1.03);
-}
-
-.rec-img-container {
- aspect-ratio: 2 / 3;
- /* Poster style */
- border-radius: 8px;
- overflow: hidden;
- margin-bottom: 10px;
- background: #1a1a1a;
-}
-
-.rec-img-container img {
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-.rec-title {
- font-size: 13px;
- font-weight: 600;
- color: #fff;
- margin-bottom: 2px;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.rec-meta {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.5);
-}
-
-/* Cast Specifics */
-.cast-member {
- flex: 0 0 100px;
- /* Reduced from 140px */
- text-align: center;
-}
-
-.cast-avatar {
- width: 100px;
- /* Reduced from 140px */
- height: 100px;
- border-radius: 50%;
- overflow: hidden;
- margin-bottom: 8px;
- /* Reduced from 12px */
- background: #1a1a1a;
- transition: transform 0.3s;
-}
-
-.cast-member:hover .cast-avatar {
- transform: scale(1.05);
-}
-
-.cast-avatar img {
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-/* Hide avatar if it's a placeholder */
-.cast-member.no-photo .cast-avatar {
- display: none;
-}
-
-.cast-name {
- font-size: 13px;
- /* Reduced from 14px */
- font-weight: 600;
- color: #fff;
- margin-bottom: 2px;
-}
-
-.cast-role {
- font-size: 11px;
- /* Reduced from 12px */
- color: rgba(255, 255, 255, 0.5);
-}
-
-/* Clickable cast hover effect */
-a.cast-member:hover .cast-name {
- text-decoration: underline;
- color: var(--color-accent, #e5c07b);
-}
-
-/* ============================================
- Animations
- ============================================ */
-@keyframes fadeIn {
- from {
- opacity: 0;
- transform: translateY(20px);
- }
-
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* ============================================
- Responsive Design
- ============================================ */
-@media (max-width: 1024px) {
- .video-theater {
- height: 60vh;
- min-height: auto;
- }
-
- .movie-details {
- margin-top: -100px;
- }
-
- .info-title {
- font-size: 40px;
- }
-
- .movie-details__container,
- .content-container {
- padding: 0 30px;
- }
-
- .carousel-wrapper {
- margin: 0 -30px;
- padding: 0 30px;
- }
-}
-
-@media (max-width: 768px) {
- .watch-header {
- padding: 0 20px;
- height: 60px;
- }
-
- .video-theater {
- height: 50vh;
- }
-
- .info-title {
- font-size: 32px;
- }
-
- .info-actions {
- flex-wrap: wrap;
- }
-
- .action-btn {
- flex: 0 0 auto;
- justify-content: center;
- }
-
- .movie-details__container,
- .content-container {
- padding: 0 20px;
- }
-
- .carousel-wrapper {
- margin: 0 -20px;
- padding: 0 20px;
- }
-
- .rec-card {
- flex: 0 0 140px;
- }
-}
-
-/* Toast Override */
-.toast-container {
- z-index: 2000;
-}
-
-/* ============================================
- Netflix Title Page Styles
- ============================================ */
-
-/* Player Section */
-.title-player-section {
- padding-top: 70px;
- background: #000;
-}
-
-.title-player-section .video-theater__player {
- max-width: 1600px;
- margin: 0 auto;
- aspect-ratio: 16 / 9;
- background: #000;
- border-radius: 0;
-}
-
-/* Title Details Container */
-.title-details {
- background: #141414;
- padding: 40px 0 60px;
-}
-
-.title-details__container {
- max-width: 1400px;
- margin: 0 auto;
- padding: 0 60px;
-}
-
-/* Title Info */
-.title-info {
- margin-bottom: 32px;
-}
-
-.title-info__name {
- font-family: var(--font-display);
- font-size: 2.5rem;
- font-weight: 700;
- color: #fff;
- margin: 0 0 12px;
- letter-spacing: -0.5px;
-}
-
-.title-info__meta {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 16px;
- font-size: 15px;
- color: rgba(255, 255, 255, 0.7);
-}
-
-.title-info__meta .meta-quality {
- border: 1px solid rgba(255, 255, 255, 0.4);
- padding: 2px 6px;
- border-radius: 4px;
- font-size: 11px;
- font-weight: 600;
-}
-
-.title-info__meta .meta-divider {
- width: 4px;
- height: 4px;
- background: rgba(255, 255, 255, 0.4);
- border-radius: 50%;
-}
-
-.title-info__description {
- font-size: 1rem;
- line-height: 1.6;
- color: rgba(255, 255, 255, 0.75);
- margin: 0 0 24px;
- max-width: 800px;
-}
-
-/* Title Action Buttons */
-.title-actions {
- display: flex;
- gap: 12px;
- align-items: center;
-}
-
-.title-action-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
- border-radius: 50%;
- color: #fff;
- cursor: pointer;
- transition: all 0.2s ease;
-}
-
-.title-action-btn--icon {
- width: 44px;
- height: 44px;
-}
-
-.title-action-btn:hover {
- background: rgba(255, 255, 255, 0.2);
- transform: scale(1.05);
-}
-
-.title-action-btn.active {
- background: #fff;
- color: #000;
- border-color: #fff;
-}
-
-/* ============================================
- Netflix Floating Pill Tabs
- ============================================ */
-.title-tabs {
- display: flex;
- gap: 8px;
- margin: 40px 0 24px;
- padding: 0;
- border-bottom: none;
-}
-
-.title-tab {
- padding: 10px 24px;
- background: rgba(255, 255, 255, 0.08);
- border: none;
- border-radius: 9999px;
- color: rgba(255, 255, 255, 0.8);
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s ease;
-}
-
-.title-tab:hover {
- background: rgba(255, 255, 255, 0.15);
- color: #fff;
-}
-
-.title-tab.active {
- background: rgba(255, 255, 255, 0.2);
- color: #fff;
-}
-
-/* Title Panels */
-.title-panel {
- animation: fadeIn 0.3s ease;
-}
-
-/* ============================================
- Episode Buttons Grid (Netflix Style)
- ============================================ */
-.episodes-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 16px;
-}
-
-.episodes-title {
- font-size: 1.25rem;
- font-weight: 600;
- color: #fff;
- margin: 0;
-}
-
-.episodes-count {
- font-size: 0.9rem;
- color: rgba(255, 255, 255, 0.5);
-}
-
-.episodes-grid {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
-}
-
-.episode-btn {
- min-width: 54px;
- height: 44px;
- padding: 10px 18px;
- background: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.15);
- border-radius: 8px;
- color: #fff;
- font-size: 0.95rem;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s ease;
-}
-
-.episode-btn:hover {
- background: rgba(255, 255, 255, 0.2);
- transform: scale(1.03);
-}
-
-.episode-btn.active {
- background: #e50914;
- border-color: #e50914;
- color: #fff;
-}
-
-/* ============================================
- More Like This Section
- ============================================ */
-.rec-section {
- margin-top: 32px;
- padding-top: 24px;
- border-top: 1px solid rgba(255, 255, 255, 0.08);
-}
-
-.rec-section:first-child {
- margin-top: 0;
- padding-top: 0;
- border-top: none;
-}
-
-.rec-section-title {
- font-size: 1.2rem;
- font-weight: 600;
- color: #fff;
- margin: 0 0 16px;
-}
-
-.rec-carousel {
- display: flex;
- gap: 12px;
- overflow-x: auto;
- padding-bottom: 16px;
- scroll-behavior: smooth;
- scrollbar-width: none;
- -ms-overflow-style: none;
-}
-
-.rec-carousel::-webkit-scrollbar {
- display: none;
-}
-
-/* Netflix Poster Cards - 150x210px */
-.rec-card {
- flex: 0 0 150px;
- text-decoration: none;
- color: #fff;
- transition: transform 0.2s ease;
-}
-
-.rec-card:hover {
- transform: scale(1.05);
-}
-
-.rec-card .rec-img-container {
- width: 150px;
- height: 210px;
- border-radius: 4px;
- overflow: hidden;
- background: #2a2a2a;
- margin-bottom: 8px;
-}
-
-.rec-card .rec-img-container img {
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-.rec-card-title {
- font-size: 0.85rem;
- font-weight: 600;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- margin-bottom: 2px;
-}
-
-.rec-card-meta {
- font-size: 0.75rem;
- color: rgba(255, 255, 255, 0.5);
-}
-
-/* ============================================
- Details Panel - Cast & Crew
- ============================================ */
-.details-section {
- margin-bottom: 32px;
-}
-
-.details-section__title {
- font-size: 1.2rem;
- font-weight: 600;
- color: #fff;
- margin: 0 0 16px;
-}
-
-.cast-carousel {
- display: flex;
- gap: 16px;
- overflow-x: auto;
- padding-bottom: 16px;
- scrollbar-width: none;
- -ms-overflow-style: none;
-}
-
-.cast-carousel::-webkit-scrollbar {
- display: none;
-}
-
-/* ============================================
- Responsive - Title Page
- ============================================ */
-@media (max-width: 1024px) {
- .title-details__container {
- padding: 0 40px;
- }
-
- .title-info__name {
- font-size: 2rem;
- }
-}
-
-@media (max-width: 768px) {
- .title-details__container {
- padding: 0 20px;
- }
-
- .title-info__name {
- font-size: 1.5rem;
- }
-
- .title-tabs {
- flex-wrap: wrap;
- }
-
- .title-tab {
- padding: 8px 16px;
- font-size: 13px;
- }
-
- .rec-card {
- flex: 0 0 120px;
- }
-
- .rec-card .rec-img-container {
- width: 120px;
- height: 168px;
- }
-}
\ No newline at end of file
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
deleted file mode 100755
index 50d002c..0000000
--- a/frontend/vite.config.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { defineConfig } from 'vite'
-import { resolve } from 'path'
-
-export default defineConfig({
- root: '.',
- server: {
- port: 3000,
- proxy: {
- '/api': {
- target: 'http://127.0.0.1:8000',
- changeOrigin: true
- }
- }
- },
- build: {
- outDir: 'dist',
- assetsDir: 'assets',
- rollupOptions: {
- input: {
- main: resolve(__dirname, 'index.html'),
- watch: resolve(__dirname, 'watch.html'),
- download: resolve(__dirname, 'download.html')
- }
- }
- }
-})
diff --git a/frontend/watch.html b/frontend/watch.html
deleted file mode 100755
index c61bc20..0000000
--- a/frontend/watch.html
+++ /dev/null
@@ -1,451 +0,0 @@
-
-
-
-
-
-
- StreamFlix - Movie Details
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Loading...
-
-
- 98% Match
- 2024
- PG-13
- hd HD
-
-
-
Loading description...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- movie
- Series
-
-
Loading...
-
-
- 98% Match
- 2024
- TV-MA
- 2h 30m
- HD
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Episodes
-
-
-
-
-
-
- Loading episodes...
-
-
-
-
-
-
-
-
-
No trailers available
-
-
-
-
-
-
-
-
-
-
About this movie
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- search
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/generate_android_icons.sh b/generate_android_icons.sh
deleted file mode 100755
index 21670a4..0000000
--- a/generate_android_icons.sh
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/bin/bash
-# Generate Android App Icons from source image
-# Usage: ./generate_android_icons.sh
-
-SOURCE_IMAGE="${1:-streamflix_icon.png}"
-RES_DIR="frontend/android/app/src/main/res"
-
-if [ ! -f "$SOURCE_IMAGE" ]; then
- echo "⌠Source image not found: $SOURCE_IMAGE"
- echo " Usage: ./generate_android_icons.sh "
- exit 1
-fi
-
-echo "🎨 Generating Android launcher icons from: $SOURCE_IMAGE"
-
-resize_image() {
- local input=$1
- local output=$2
- local size=$3
-
- if command -v convert &> /dev/null; then
- convert "$input" -resize "${size}x${size}" "$output"
- elif command -v sips &> /dev/null; then
- cp "$input" "$output"
- sips -z "$size" "$size" "$output" > /dev/null 2>&1
- else
- echo "⌠Neither ImageMagick (convert) nor sips is available."
- exit 1
- fi
-}
-
-# Generate icons for each density
-generate_icons() {
- local density=$1
- local size=$2
- local fg_size=$3
- local dir="$RES_DIR/mipmap-$density"
-
- mkdir -p "$dir"
-
- echo " 📱 $density: ${size}x${size}px (foreground: ${fg_size}x${fg_size}px)"
-
- resize_image "$SOURCE_IMAGE" "$dir/ic_launcher.png" "$size"
- resize_image "$SOURCE_IMAGE" "$dir/ic_launcher_round.png" "$size"
- resize_image "$SOURCE_IMAGE" "$dir/ic_launcher_foreground.png" "$fg_size"
-}
-
-# Android density -> icon size -> foreground size
-generate_icons "mdpi" 48 108
-generate_icons "hdpi" 72 162
-generate_icons "xhdpi" 96 216
-generate_icons "xxhdpi" 144 324
-generate_icons "xxxhdpi" 192 432
-
-echo ""
-echo "✅ Icons generated successfully!"
-echo " Now rebuild the APK with: ./build_apk.sh"
diff --git a/genre_response.json b/genre_response.json
new file mode 100644
index 0000000..3c75409
Binary files /dev/null and b/genre_response.json differ
diff --git a/list_response.json b/list_response.json
new file mode 100644
index 0000000..e2f036a
Binary files /dev/null and b/list_response.json differ
diff --git a/releases.json b/releases.json
deleted file mode 100755
index ae18cd6..0000000
--- a/releases.json
+++ /dev/null
@@ -1 +0,0 @@
-[{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/273844687","assets_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/273844687/assets","upload_url":"https://uploads.github.com/repos/vndangkhoa/Streamflow/releases/273844687/assets{?name,label}","html_url":"https://github.com/vndangkhoa/Streamflow/releases/tag/v1.3.0","id":273844687,"author":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOQsrZjs4QUonP","tag_name":"v1.3.0","target_commitish":"main","name":"v1.3.0 - YouTube Style Branding & Search Overhaul","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-01-02T16:21:11Z","updated_at":"2026-01-02T16:29:40Z","published_at":"2026-01-02T16:27:31Z","assets":[{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/assets/335553172","id":335553172,"node_id":"RA_kwDOQsrZjs4UACKU","name":"StreamFlix-TV-v1.3.0.apk","label":null,"uploader":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/vnd.android.package-archive","state":"uploaded","size":9778548,"digest":"sha256:65957ed1e289a9c2531aa1c875e8014bf178a080aabe3050567bba3898b77ddd","download_count":1,"created_at":"2026-01-02T16:29:36Z","updated_at":"2026-01-02T16:29:40Z","browser_download_url":"https://github.com/vndangkhoa/Streamflow/releases/download/v1.3.0/StreamFlix-TV-v1.3.0.apk"}],"tarball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tarball/v1.3.0","zipball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/zipball/v1.3.0","body":"## 📋 Latest Release: v1.3.0\r\n\r\n**What's New in v1.3.0:**\r\n- 🔠**Search Overhaul:** Fixed UI rendering, added keyboard/voice support, and verified backend connectivity.\r\n- 🟥 **Solid Red Branding:** Pure solid red adaptive icon and banner for a cleaner TV home screen look.\r\n- ⚡ **Navigation Polish:** Smarter focus management and sidebar transitions.\r\n- 🞠**Stability:** Fixed compilation errors and optimized network requests."},{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/272808459","assets_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/272808459/assets","upload_url":"https://uploads.github.com/repos/vndangkhoa/Streamflow/releases/272808459/assets{?name,label}","html_url":"https://github.com/vndangkhoa/Streamflow/releases/tag/v1.2.0","id":272808459,"author":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOQsrZjs4QQroL","tag_name":"v1.2.0","target_commitish":"main","name":"v1.2.0: Android Phone & TV ","draft":false,"immutable":false,"prerelease":false,"created_at":"2025-12-25T16:31:35Z","updated_at":"2025-12-25T16:41:06Z","published_at":"2025-12-25T16:35:33Z","assets":[{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/assets/332874171","id":332874171,"node_id":"RA_kwDOQsrZjs4T10G7","name":"StreamFlix-TV.apk","label":null,"uploader":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/vnd.android.package-archive","state":"uploaded","size":10586680,"digest":"sha256:79be74bebb7801ffdda39a90e37b3abc2e6609a8678522ce069022fd3b817007","download_count":9,"created_at":"2025-12-25T16:40:48Z","updated_at":"2025-12-25T16:40:50Z","browser_download_url":"https://github.com/vndangkhoa/Streamflow/releases/download/v1.2.0/StreamFlix-TV.apk"},{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/assets/332874168","id":332874168,"node_id":"RA_kwDOQsrZjs4T10G4","name":"StreamFlix.apk","label":null,"uploader":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/vnd.android.package-archive","state":"uploaded","size":7400856,"digest":"sha256:c0b761223b51e6c21fb594aa6092923a8984ec5a2bba49d60f2a4116c6065574","download_count":5,"created_at":"2025-12-25T16:40:47Z","updated_at":"2025-12-25T16:40:48Z","browser_download_url":"https://github.com/vndangkhoa/Streamflow/releases/download/v1.2.0/StreamFlix.apk"}],"tarball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tarball/v1.2.0","zipball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/zipball/v1.2.0","body":""},{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/272752934","assets_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/272752934/assets","upload_url":"https://uploads.github.com/repos/vndangkhoa/Streamflow/releases/272752934/assets{?name,label}","html_url":"https://github.com/vndangkhoa/Streamflow/releases/tag/v1.1.0","id":272752934,"author":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOQsrZjs4QQeEm","tag_name":"v1.1.0","target_commitish":"main","name":"v1.1.0 - Native Android TV App","draft":false,"immutable":false,"prerelease":false,"created_at":"2025-12-25T00:26:06Z","updated_at":"2025-12-25T02:34:07Z","published_at":"2025-12-25T02:20:15Z","assets":[{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/assets/332705529","id":332705529,"node_id":"RA_kwDOQsrZjs4T1K75","name":"StreamFlix-TV-v1.1.0-debug.apk","label":null,"uploader":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/vnd.android.package-archive","state":"uploaded","size":8696027,"digest":"sha256:6e0035f9f9cd57c50bec72c756b91d9cc28084ee871ad4c31d69c168c42c3eb7","download_count":10,"created_at":"2025-12-25T02:33:59Z","updated_at":"2025-12-25T02:34:05Z","browser_download_url":"https://github.com/vndangkhoa/Streamflow/releases/download/v1.1.0/StreamFlix-TV-v1.1.0-debug.apk"}],"tarball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tarball/v1.1.0","zipball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/zipball/v1.1.0","body":""},{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/272694299","assets_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/272694299/assets","upload_url":"https://uploads.github.com/repos/vndangkhoa/Streamflow/releases/272694299/assets{?name,label}","html_url":"https://github.com/vndangkhoa/Streamflow/releases/tag/v1.0.10","id":272694299,"author":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOQsrZjs4QQPwb","tag_name":"v1.0.10","target_commitish":"main","name":"StreamFlix v1.0.10","draft":false,"immutable":false,"prerelease":false,"created_at":"2025-12-24T13:59:56Z","updated_at":"2025-12-25T00:23:38Z","published_at":"2025-12-24T14:04:03Z","assets":[{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/assets/332662105","id":332662105,"node_id":"RA_kwDOQsrZjs4T1AVZ","name":"StreamFlix-TV.apk","label":null,"uploader":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/vnd.android.package-archive","state":"uploaded","size":7400856,"digest":"sha256:c0b761223b51e6c21fb594aa6092923a8984ec5a2bba49d60f2a4116c6065574","download_count":3,"created_at":"2025-12-25T00:07:51Z","updated_at":"2025-12-25T00:07:56Z","browser_download_url":"https://github.com/vndangkhoa/Streamflow/releases/download/v1.0.10/StreamFlix-TV.apk"},{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/assets/332519039","id":332519039,"node_id":"RA_kwDOQsrZjs4T0dZ_","name":"StreamFlix.apk","label":null,"uploader":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/vnd.android.package-archive","state":"uploaded","size":7426421,"digest":"sha256:d52fcc2c4e1fb298c81892cb5d9c6c1fc729eb6d750ef660a0f4ea1edac16b93","download_count":6,"created_at":"2025-12-24T14:03:55Z","updated_at":"2025-12-25T00:11:06Z","browser_download_url":"https://github.com/vndangkhoa/Streamflow/releases/download/v1.0.10/StreamFlix.apk"}],"tarball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tarball/v1.0.10","zipball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/zipball/v1.0.10","body":"### What's New in v1.0.10\r\n- 🎮 **Android TV Remote Support:** D-pad navigation now works\r\n- 🎨 **New App Icons:** Updated StreamFlix branding\r\n- ╠**Focus Indicator:** Red glow shows selected item on TV\r\n\r\n### Download\r\nClick **StreamFlix.apk** below to download."},{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/272689225","assets_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/272689225/assets","upload_url":"https://uploads.github.com/repos/vndangkhoa/Streamflow/releases/272689225/assets{?name,label}","html_url":"https://github.com/vndangkhoa/Streamflow/releases/tag/v1.0.9","id":272689225,"author":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOQsrZjs4QQOhJ","tag_name":"v1.0.9","target_commitish":"main","name":"StreamFlix v1.0.9","draft":false,"immutable":false,"prerelease":false,"created_at":"2025-12-24T13:10:21Z","updated_at":"2025-12-24T13:25:20Z","published_at":"2025-12-24T13:25:20Z","assets":[{"url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases/assets/332512044","id":332512044,"node_id":"RA_kwDOQsrZjs4T0bss","name":"app-debug.apk","label":null,"uploader":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/vnd.android.package-archive","state":"uploaded","size":7452276,"digest":"sha256:34cc37b492f8ad8934e8084a31228290466a141e12a63261720d1393ffa2cb80","download_count":3,"created_at":"2025-12-24T13:24:55Z","updated_at":"2025-12-24T13:25:00Z","browser_download_url":"https://github.com/vndangkhoa/Streamflow/releases/download/v1.0.9/app-debug.apk"}],"tarball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tarball/v1.0.9","zipball_url":"https://api.github.com/repos/vndangkhoa/Streamflow/zipball/v1.0.9","body":"### What's New in v1.0.9\r\n- 📱 **GitHub Releases APK:** Android APK now hosted on GitHub Releases\r\n- 🎨 **New App Icon:** Updated Android launcher icon with StreamFlix branding \r\n- 🔄 **Simplified Deployment:** No Docker rebuild needed for APK updates\r\n\r\n### Download\r\nClick **StreamFlix.apk** below to download the Android app."}]
diff --git a/run_status.json b/run_status.json
deleted file mode 100755
index 9004de9..0000000
--- a/run_status.json
+++ /dev/null
@@ -1 +0,0 @@
-{"id":20663313993,"name":"Release APKs","node_id":"WFR_kwLOQsrZjs8AAAAEz6EmSQ","head_branch":"v1.3.2","head_sha":"e4120b79549fc56392e97f6b5cc76eaa13e716da","path":".github/workflows/release.yml","display_title":"chore: bump version to 1.3.2 for release test","run_number":1,"event":"push","status":"in_progress","conclusion":null,"workflow_id":220271808,"check_suite_id":53413718022,"check_suite_node_id":"CS_kwDOQsrZjs8AAAAMb7SoBg","url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993","html_url":"https://github.com/vndangkhoa/Streamflow/actions/runs/20663313993","pull_requests":[],"created_at":"2026-01-02T17:42:13Z","updated_at":"2026-01-02T17:42:16Z","actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-01-02T17:42:13Z","triggering_actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/jobs","logs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/logs","check_suite_url":"https://api.github.com/repos/vndangkhoa/Streamflow/check-suites/53413718022","artifacts_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/artifacts","cancel_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/cancel","rerun_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/workflows/220271808","head_commit":{"id":"e4120b79549fc56392e97f6b5cc76eaa13e716da","tree_id":"357de69ba2fb73b9f035dfd66ee138fae4b494f6","message":"chore: bump version to 1.3.2 for release test","timestamp":"2026-01-02T17:42:08Z","author":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"},"committer":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"}},"repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"},"head_repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"}}
diff --git a/runs.json b/runs.json
deleted file mode 100755
index 5dcebaa..0000000
--- a/runs.json
+++ /dev/null
@@ -1 +0,0 @@
-{"total_count":9,"workflow_runs":[{"id":20662891305,"name":"StreamFlow CI/CD","node_id":"WFR_kwLOQsrZjs8AAAAEz5qzKQ","head_branch":"main","head_sha":"18935418643af61063aeb5be38702a42da110695","path":".github/workflows/ci.yml","display_title":"fix(mobile): use public access modifier for onDestroy override","run_number":9,"event":"push","status":"completed","conclusion":"failure","workflow_id":220256711,"check_suite_id":53412535807,"check_suite_node_id":"CS_kwDOQsrZjs8AAAAMb6Kd_w","url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662891305","html_url":"https://github.com/vndangkhoa/Streamflow/actions/runs/20662891305","pull_requests":[],"created_at":"2026-01-02T17:17:40Z","updated_at":"2026-01-02T17:21:42Z","actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-01-02T17:17:40Z","triggering_actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662891305/jobs","logs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662891305/logs","check_suite_url":"https://api.github.com/repos/vndangkhoa/Streamflow/check-suites/53412535807","artifacts_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662891305/artifacts","cancel_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662891305/cancel","rerun_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662891305/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/workflows/220256711","head_commit":{"id":"18935418643af61063aeb5be38702a42da110695","tree_id":"ca7307c478c8fbf2e145e56206601fa3a3d255b9","message":"fix(mobile): use public access modifier for onDestroy override","timestamp":"2026-01-02T17:17:36Z","author":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"},"committer":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"}},"repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"},"head_repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"}},{"id":20662766019,"name":"StreamFlow CI/CD","node_id":"WFR_kwLOQsrZjs8AAAAEz5jJww","head_branch":"main","head_sha":"62c30d5762da961d3b0dba93fac94d9b4c60979e","path":".github/workflows/ci.yml","display_title":"ci: include Android PNG resources in the repository and fix build fai…","run_number":8,"event":"push","status":"completed","conclusion":"failure","workflow_id":220256711,"check_suite_id":53412194275,"check_suite_node_id":"CS_kwDOQsrZjs8AAAAMb51n4w","url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662766019","html_url":"https://github.com/vndangkhoa/Streamflow/actions/runs/20662766019","pull_requests":[],"created_at":"2026-01-02T17:10:52Z","updated_at":"2026-01-02T17:15:27Z","actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-01-02T17:10:52Z","triggering_actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662766019/jobs","logs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662766019/logs","check_suite_url":"https://api.github.com/repos/vndangkhoa/Streamflow/check-suites/53412194275","artifacts_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662766019/artifacts","cancel_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662766019/cancel","rerun_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662766019/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/workflows/220256711","head_commit":{"id":"62c30d5762da961d3b0dba93fac94d9b4c60979e","tree_id":"bf53452c7a76d525276936be60ceed2ebb76b173","message":"ci: include Android PNG resources in the repository and fix build failures","timestamp":"2026-01-02T17:10:47Z","author":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"},"committer":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"}},"repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"},"head_repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"}},{"id":20662630551,"name":"StreamFlow CI/CD","node_id":"WFR_kwLOQsrZjs8AAAAEz5a4lw","head_branch":"main","head_sha":"214259c998a20ae88b3e2e2c0fbe9822452170ba","path":".github/workflows/ci.yml","display_title":"ci: upgrade to Java 21 for Android builds","run_number":7,"event":"push","status":"completed","conclusion":"failure","workflow_id":220256711,"check_suite_id":53411830797,"check_suite_node_id":"CS_kwDOQsrZjs8AAAAMb5fcDQ","url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662630551","html_url":"https://github.com/vndangkhoa/Streamflow/actions/runs/20662630551","pull_requests":[],"created_at":"2026-01-02T17:03:55Z","updated_at":"2026-01-02T17:08:02Z","actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-01-02T17:03:55Z","triggering_actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662630551/jobs","logs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662630551/logs","check_suite_url":"https://api.github.com/repos/vndangkhoa/Streamflow/check-suites/53411830797","artifacts_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662630551/artifacts","cancel_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662630551/cancel","rerun_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20662630551/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/workflows/220256711","head_commit":{"id":"214259c998a20ae88b3e2e2c0fbe9822452170ba","tree_id":"0142052ae1f6dc4cbc74ea471c1811ac620d9429","message":"ci: upgrade to Java 21 for Android builds","timestamp":"2026-01-02T17:03:51Z","author":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"},"committer":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"}},"repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"},"head_repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"}}]}
diff --git a/runs_test.json b/runs_test.json
deleted file mode 100755
index 78d6b5e..0000000
--- a/runs_test.json
+++ /dev/null
@@ -1 +0,0 @@
-{"total_count":12,"workflow_runs":[{"id":20663313993,"name":"Release APKs","node_id":"WFR_kwLOQsrZjs8AAAAEz6EmSQ","head_branch":"v1.3.2","head_sha":"e4120b79549fc56392e97f6b5cc76eaa13e716da","path":".github/workflows/release.yml","display_title":"chore: bump version to 1.3.2 for release test","run_number":1,"event":"push","status":"in_progress","conclusion":null,"workflow_id":220271808,"check_suite_id":53413718022,"check_suite_node_id":"CS_kwDOQsrZjs8AAAAMb7SoBg","url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993","html_url":"https://github.com/vndangkhoa/Streamflow/actions/runs/20663313993","pull_requests":[],"created_at":"2026-01-02T17:42:13Z","updated_at":"2026-01-02T17:42:16Z","actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-01-02T17:42:13Z","triggering_actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/jobs","logs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/logs","check_suite_url":"https://api.github.com/repos/vndangkhoa/Streamflow/check-suites/53413718022","artifacts_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/artifacts","cancel_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/cancel","rerun_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313993/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/workflows/220271808","head_commit":{"id":"e4120b79549fc56392e97f6b5cc76eaa13e716da","tree_id":"357de69ba2fb73b9f035dfd66ee138fae4b494f6","message":"chore: bump version to 1.3.2 for release test","timestamp":"2026-01-02T17:42:08Z","author":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"},"committer":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"}},"repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"},"head_repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"}},{"id":20663313758,"name":"StreamFlow CI/CD","node_id":"WFR_kwLOQsrZjs8AAAAEz6ElXg","head_branch":"main","head_sha":"e4120b79549fc56392e97f6b5cc76eaa13e716da","path":".github/workflows/ci.yml","display_title":"chore: bump version to 1.3.2 for release test","run_number":11,"event":"push","status":"in_progress","conclusion":null,"workflow_id":220256711,"check_suite_id":53413717246,"check_suite_node_id":"CS_kwDOQsrZjs8AAAAMb7Sk_g","url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313758","html_url":"https://github.com/vndangkhoa/Streamflow/actions/runs/20663313758","pull_requests":[],"created_at":"2026-01-02T17:42:12Z","updated_at":"2026-01-02T17:42:18Z","actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"run_attempt":1,"referenced_workflows":[],"run_started_at":"2026-01-02T17:42:12Z","triggering_actor":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"jobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313758/jobs","logs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313758/logs","check_suite_url":"https://api.github.com/repos/vndangkhoa/Streamflow/check-suites/53413717246","artifacts_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313758/artifacts","cancel_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313758/cancel","rerun_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/runs/20663313758/rerun","previous_attempt_url":null,"workflow_url":"https://api.github.com/repos/vndangkhoa/Streamflow/actions/workflows/220256711","head_commit":{"id":"e4120b79549fc56392e97f6b5cc76eaa13e716da","tree_id":"357de69ba2fb73b9f035dfd66ee138fae4b494f6","message":"chore: bump version to 1.3.2 for release test","timestamp":"2026-01-02T17:42:08Z","author":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"},"committer":{"name":"vndangkhoa","email":"vndangkhoa@users.noreply.github.com"}},"repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"},"head_repository":{"id":1120590222,"node_id":"R_kgDOQsrZjg","name":"Streamflow","full_name":"vndangkhoa/Streamflow","private":false,"owner":{"login":"vndangkhoa","id":60398697,"node_id":"MDQ6VXNlcjYwMzk4Njk3","avatar_url":"https://avatars.githubusercontent.com/u/60398697?v=4","gravatar_id":"","url":"https://api.github.com/users/vndangkhoa","html_url":"https://github.com/vndangkhoa","followers_url":"https://api.github.com/users/vndangkhoa/followers","following_url":"https://api.github.com/users/vndangkhoa/following{/other_user}","gists_url":"https://api.github.com/users/vndangkhoa/gists{/gist_id}","starred_url":"https://api.github.com/users/vndangkhoa/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vndangkhoa/subscriptions","organizations_url":"https://api.github.com/users/vndangkhoa/orgs","repos_url":"https://api.github.com/users/vndangkhoa/repos","events_url":"https://api.github.com/users/vndangkhoa/events{/privacy}","received_events_url":"https://api.github.com/users/vndangkhoa/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/vndangkhoa/Streamflow","description":null,"fork":false,"url":"https://api.github.com/repos/vndangkhoa/Streamflow","forks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/forks","keys_url":"https://api.github.com/repos/vndangkhoa/Streamflow/keys{/key_id}","collaborators_url":"https://api.github.com/repos/vndangkhoa/Streamflow/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/vndangkhoa/Streamflow/teams","hooks_url":"https://api.github.com/repos/vndangkhoa/Streamflow/hooks","issue_events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/events{/number}","events_url":"https://api.github.com/repos/vndangkhoa/Streamflow/events","assignees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/assignees{/user}","branches_url":"https://api.github.com/repos/vndangkhoa/Streamflow/branches{/branch}","tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/tags","blobs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/refs{/sha}","trees_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/trees{/sha}","statuses_url":"https://api.github.com/repos/vndangkhoa/Streamflow/statuses/{sha}","languages_url":"https://api.github.com/repos/vndangkhoa/Streamflow/languages","stargazers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/stargazers","contributors_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contributors","subscribers_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscribers","subscription_url":"https://api.github.com/repos/vndangkhoa/Streamflow/subscription","commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/commits{/sha}","git_commits_url":"https://api.github.com/repos/vndangkhoa/Streamflow/git/commits{/sha}","comments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/comments{/number}","issue_comment_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues/comments{/number}","contents_url":"https://api.github.com/repos/vndangkhoa/Streamflow/contents/{+path}","compare_url":"https://api.github.com/repos/vndangkhoa/Streamflow/compare/{base}...{head}","merges_url":"https://api.github.com/repos/vndangkhoa/Streamflow/merges","archive_url":"https://api.github.com/repos/vndangkhoa/Streamflow/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/vndangkhoa/Streamflow/downloads","issues_url":"https://api.github.com/repos/vndangkhoa/Streamflow/issues{/number}","pulls_url":"https://api.github.com/repos/vndangkhoa/Streamflow/pulls{/number}","milestones_url":"https://api.github.com/repos/vndangkhoa/Streamflow/milestones{/number}","notifications_url":"https://api.github.com/repos/vndangkhoa/Streamflow/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/vndangkhoa/Streamflow/labels{/name}","releases_url":"https://api.github.com/repos/vndangkhoa/Streamflow/releases{/id}","deployments_url":"https://api.github.com/repos/vndangkhoa/Streamflow/deployments"}}]}
diff --git a/streamflow.db b/streamflow.db
deleted file mode 100755
index 3953792..0000000
Binary files a/streamflow.db and /dev/null differ