Release v4: Cleanup and refactoring
Some checks failed
StreamFlow CI/CD / Backend Tests (push) Has been cancelled
StreamFlow CI/CD / Backend Lint (push) Has been cancelled
StreamFlow CI/CD / Frontend Tests (push) Has been cancelled
StreamFlow CI/CD / Android TV Build (push) Has been cancelled
StreamFlow CI/CD / Docker Build (push) Has been cancelled
StreamFlow CI/CD / Docker Publish (push) Has been cancelled

This commit is contained in:
vndangkhoa 2026-03-03 07:55:00 +07:00
parent 9b2339b85d
commit 3009f94fe9
36 changed files with 777 additions and 1879 deletions

View file

@ -1,4 +1,4 @@
# StreamFlow V3.9.2 # kv-netflix V4
A high-performance video streaming web application with a pure Go backend and modern React + Tailwind frontend. A high-performance video streaming web application with a pure Go backend and modern React + Tailwind frontend.
@ -31,7 +31,7 @@ version: '3.8'
services: services:
streamflow: streamflow:
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v3.9.2 image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v4
container_name: streamflow container_name: streamflow
platform: linux/amd64 platform: linux/amd64
ports: ports:
@ -119,7 +119,11 @@ Streamflow/
## Changelog ## Changelog
### v3.9.2 (Current) ### v4 (Current)
- Deployed v4 to Forgejo and Docker Registry
- Refactored frontend and cleaned up repository
### v3.9.2
- Fixed Android TV local IP issue by replacing it with production backend URL - Fixed Android TV local IP issue by replacing it with production backend URL
- Rebuilt Android TV APK and updated the frontend static bundle - Rebuilt Android TV APK and updated the frontend static bundle

View file

@ -1,32 +0,0 @@
package main
import (
"crypto/tls"
"fmt"
"net/http"
"time"
)
func main() {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{
Transport: tr,
Timeout: 15 * time.Second,
}
url := "https://www.google.com/favicon.ico"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Printf("err: %v\n", err)
return
}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("do err: %v\n", err)
return
}
fmt.Printf("status: %d\n", resp.StatusCode)
}

View file

@ -299,10 +299,11 @@ func (h *Handler) GetMovieDetail(w http.ResponseWriter, r *http.Request) {
if len(primaryMovie.Episodes) > 0 { if len(primaryMovie.Episodes) > 0 {
uniqueEps := make([]models.Episode, 0) uniqueEps := make([]models.Episode, 0)
seenEpNums := make(map[int]bool) seenEpNums := make(map[string]bool)
for _, ep := range primaryMovie.Episodes { for _, ep := range primaryMovie.Episodes {
if !seenEpNums[ep.Number] { key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
seenEpNums[ep.Number] = true if !seenEpNums[key] {
seenEpNums[key] = true
uniqueEps = append(uniqueEps, ep) uniqueEps = append(uniqueEps, ep)
} }
} }
@ -431,21 +432,23 @@ func (h *Handler) mergeMovieMetadata(existing, new *models.RophimMovie) {
existing.Quality = new.Quality existing.Quality = new.Quality
} }
epMap := make(map[int]int) epMap := make(map[string]int)
for i := range existing.Episodes { for i, ep := range existing.Episodes {
epMap[existing.Episodes[i].Number] = i key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
epMap[key] = i
} }
for i := range new.Episodes { for i := range new.Episodes {
newEp := &new.Episodes[i] newEp := &new.Episodes[i]
if idx, exists := epMap[newEp.Number]; exists { key := fmt.Sprintf("%d-%s", newEp.Number, newEp.ServerName)
if idx, exists := epMap[key]; exists {
if existing.Episodes[idx].URL == "" && newEp.URL != "" { if existing.Episodes[idx].URL == "" && newEp.URL != "" {
existing.Episodes[idx].URL = newEp.URL existing.Episodes[idx].URL = newEp.URL
existing.Episodes[idx].Title = newEp.Title existing.Episodes[idx].Title = newEp.Title
existing.Episodes[idx].ServerName = newEp.ServerName existing.Episodes[idx].ServerName = newEp.ServerName
} }
} else { } else {
epMap[newEp.Number] = len(existing.Episodes) epMap[key] = len(existing.Episodes)
existing.Episodes = append(existing.Episodes, *newEp) existing.Episodes = append(existing.Episodes, *newEp)
} }
} }

View file

@ -53,11 +53,38 @@ func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, er
} }
func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) { func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
// e.g. https://phim30.me/the-loai/hanh-dong?page=1 if category == "" || category == "home" {
catURL := fmt.Sprintf("%s/the-loai/%s?page=%d", Phim30BaseURL, category, page) homeURL := fmt.Sprintf("%s/?page=%d", Phim30BaseURL, page)
return p.scrapeMovieList(homeURL)
}
var path string
switch category {
case "phim-le", "phim-bo", "phim-sap-chieu":
path = fmt.Sprintf("danh-sach/%s", category)
default:
// Assume everything else is a Genre (e.g., hanh-dong, hoat-hinh, tv-shows)
path = fmt.Sprintf("the-loai/%s", category)
}
catURL := fmt.Sprintf("%s/%s?page=%d", Phim30BaseURL, path, page)
return p.scrapeMovieList(catURL) return p.scrapeMovieList(catURL)
} }
func cleanImageUrl(rawURL string) string {
if strings.Contains(rawURL, "cdn-image-tf.phim30.me") {
// Example: https://cdn-image-tf.phim30.me/unsafe/360x0/filters:quality(90)/https%3A%2F%2Fphimimg.com%2Fupload%2Fvod%2F...
parts := strings.SplitN(rawURL, "/https", 2)
if len(parts) == 2 {
decoded, err := url.QueryUnescape("https" + parts[1])
if err == nil {
return decoded
}
}
}
return rawURL
}
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) { func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
req, err := http.NewRequest("GET", targetURL, nil) req, err := http.NewRequest("GET", targetURL, nil)
if err != nil { if err != nil {
@ -86,6 +113,10 @@ func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie,
href, _ := s.Attr("href") href, _ := s.Attr("href")
title, _ := s.Attr("title") title, _ := s.Attr("title")
if title == "" {
title = strings.TrimSpace(s.Text())
}
// Remove the base url to get the slug // Remove the base url to get the slug
slug := strings.TrimPrefix(href, "https://phim30.me/phim/") slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
@ -104,13 +135,15 @@ func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie,
} }
}) })
if title != "" && slug != "" { if title != "" && slug != "" && !strings.Contains(slug, "the-loai") && !strings.Contains(slug, "quoc-gia") && !strings.Contains(slug, "nam-phat-hanh") {
movies = append(movies, models.RophimMovie{ movies = append(movies, models.RophimMovie{
ID: slug, ID: slug,
Slug: slug, Slug: slug,
Title: title, Title: title,
OriginalTitle: title, OriginalTitle: title,
Thumbnail: thumb, Thumbnail: cleanImageUrl(thumb),
Backdrop: cleanImageUrl(thumb),
Provider: "Phim30.me",
}) })
} }
}) })
@ -165,6 +198,19 @@ func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error)
movie.Title = title movie.Title = title
movie.OriginalTitle = title movie.OriginalTitle = title
thumb := ""
doc.Find("div.movie-l-img img").Each(func(i int, img *goquery.Selection) {
if src, ok := img.Attr("src"); ok {
thumb = src
}
})
if thumb != "" {
movie.Thumbnail = cleanImageUrl(thumb)
movie.Backdrop = cleanImageUrl(thumb)
}
movie.Provider = "Phim30.me"
var eps []models.Episode var eps []models.Episode
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) { doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) {
href, _ := s.Attr("href") href, _ := s.Attr("href")

View file

@ -4,11 +4,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/PuerkitoBio/goquery"
) )
type VideoInfo struct { type VideoInfo struct {
@ -33,8 +36,36 @@ func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error)
// Check for custom extractors // Check for custom extractors
if strings.Contains(url, "phim30.me") { if strings.Contains(url, "phim30.me") {
// Currently returning the URL as-is, letting yt-dlp attempt extraction req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
// or allowing the frontend iframe to handle it directly if it's embeddable if err != nil {
return nil, fmt.Errorf("failed to create phim30 request: %v", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch phim30 page: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to parse phim30 page: %v", err)
}
streamURL, _ := doc.Find("[data-movie-player-src-value]").Attr("data-movie-player-src-value")
if streamURL != "" {
return &VideoInfo{
StreamURL: streamURL,
Resolution: "unknown",
}, nil
}
return nil, fmt.Errorf("could not find stream URL on phim30 page")
} }
// Build format selector // Build format selector

Binary file not shown.

View file

@ -1,37 +0,0 @@
# Streamflow Deployment Script
# Automates building and pushing Docker images to registries
$ErrorActionPreference = "Stop"
$VERSION = "v3.9.1"
Write-Host "=============================" -ForegroundColor Cyan
Write-Host " Streamflow Deployer " -ForegroundColor Cyan
Write-Host "=============================" -ForegroundColor Cyan
# 1. Build for linux/amd64
Write-Host "`n[1/3] Building Docker Image for linux/amd64..." -ForegroundColor White
docker buildx build --platform linux/amd64 -t streamflow:latest -t streamflow:$VERSION --load .
if ($LASTEXITCODE -ne 0) { Write-Error "Build failed"; exit 1 }
Write-Host " -> Build successful" -ForegroundColor Green
# 2. Push to Docker Hub
Write-Host "`n[2/3] Pushing to Docker Hub..." -ForegroundColor White
docker tag streamflow:latest vndangkhoa/streamflow:latest
docker tag streamflow:$VERSION vndangkhoa/streamflow:$VERSION
docker push vndangkhoa/streamflow:latest
docker push vndangkhoa/streamflow:$VERSION
if ($LASTEXITCODE -ne 0) { Write-Warning "Docker Hub push failed. Check your login." }
else { Write-Host " -> Pushed to Docker Hub" -ForegroundColor Green }
# 3. Push to Private Registry (Forgejo)
Write-Host "`n[3/3] Pushing to Private Registry..." -ForegroundColor White
docker tag streamflow:latest git.khoavo.myds.me/vndangkhoa/kv-netflix:latest
docker tag streamflow:$VERSION git.khoavo.myds.me/vndangkhoa/kv-netflix:$VERSION
docker push git.khoavo.myds.me/vndangkhoa/kv-netflix:latest
docker push git.khoavo.myds.me/vndangkhoa/kv-netflix:$VERSION
if ($LASTEXITCODE -ne 0) { Write-Warning "Private Registry push failed. Check VPN/Login." }
else { Write-Host " -> Pushed to Private Registry" -ForegroundColor Green }
Write-Host "`nDeployment Complete!" -ForegroundColor Magenta
Start-Sleep -Seconds 5

View file

@ -2,7 +2,7 @@ version: '3.8'
services: services:
streamflow: streamflow:
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v3.9.1 image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v4
container_name: streamflow container_name: streamflow
platform: linux/amd64 platform: linux/amd64
ports: ports:

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
import { Suspense, lazy } from 'react'; import { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import { ThemeProvider } from './context/ThemeContext';
const Home = lazy(() => import('./pages/Home')); const Home = lazy(() => import('./pages/Home'));
const Watch = lazy(() => import('./pages/Watch')); const Watch = lazy(() => import('./pages/Watch'));
@ -17,20 +16,18 @@ function LoadingSpinner() {
function App() { function App() {
return ( return (
<ThemeProvider> <ErrorBoundary>
<ErrorBoundary> <Router>
<Router> <Suspense fallback={<LoadingSpinner />}>
<Suspense fallback={<LoadingSpinner />}> <Routes>
<Routes> <Route path="/" element={<Home />} />
<Route path="/" element={<Home />} /> <Route path="/my-list" element={<MyList />} />
<Route path="/my-list" element={<MyList />} /> <Route path="/watch/:slug/:episode" element={<Watch />} />
<Route path="/watch/:slug/:episode" element={<Watch />} /> <Route path="/watch/:slug" element={<Watch />} />
<Route path="/watch/:slug" element={<Watch />} /> </Routes>
</Routes> </Suspense>
</Suspense> </Router>
</Router> </ErrorBoundary>
</ErrorBoundary>
</ThemeProvider>
); );
} }

View file

@ -0,0 +1,6 @@
import { MovieCard } from './MovieCard';
import type { Movie } from '../types';
export const Card = ({ movie }: { movie: Movie }) => {
return <MovieCard movie={movie} />;
};

View file

@ -2,7 +2,7 @@ import { useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useLocation, Link, useNavigate } from 'react-router-dom'; import { useLocation, Link, useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { NAV_ITEMS } from '../../constants'; import { NAV_ITEMS } from '../constants';
export const Layout = ({ children }: { children: ReactNode }) => { export const Layout = ({ children }: { children: ReactNode }) => {
const location = useLocation(); const location = useLocation();

View file

@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Play } from 'lucide-react'; import { Play, Image as ImageIcon } from 'lucide-react';
import type { Movie } from '../types'; import type { Movie } from '../types';
interface MovieCardProps { interface MovieCardProps {
@ -9,9 +10,16 @@ interface MovieCardProps {
} }
export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => { export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => {
const [imgError, setImgError] = useState(false);
const getImageUrl = (url: string, width: number) => { const getImageUrl = (url: string, width: number) => {
if (!url) return ''; if (!url) return '';
const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com'); let cleanUrl = url;
if (url.includes('img.ophim1.com')) {
cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com');
} else if (url.startsWith('//')) {
cleanUrl = `https:${url}`;
}
return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl.replace(/^https?:\/\//, ''))}&w=${width}&output=webp`; return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl.replace(/^https?:\/\//, ''))}&w=${width}&output=webp`;
}; };
@ -24,13 +32,21 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
}`} }`}
draggable={false} draggable={false}
> >
<img {!imgError ? (
src={getImageUrl(movie.thumbnail, 400)} <img
alt={movie.title} src={getImageUrl(movie.thumbnail, 250)}
className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110" alt={movie.title}
loading="lazy" onError={() => setImgError(true)}
draggable={false} className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"
/> loading="lazy"
draggable={false}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-[#222] text-gray-500 p-4 text-center">
<ImageIcon className="w-8 h-8 mb-2 opacity-50" />
<span className="text-xs font-medium leading-tight">{movie.title}</span>
</div>
)}
{/* Hover Overlay */} {/* Hover Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover/card:opacity-100 transition-all duration-500 flex items-center justify-center"> <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover/card:opacity-100 transition-all duration-500 flex items-center justify-center">

View file

@ -1,4 +1,4 @@
import type { Movie } from '../../types'; import type { Movie } from '../types';
import { Card } from './Card'; import { Card } from './Card';
export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => { export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => {

View file

@ -1,88 +0,0 @@
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 (
<>
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-24 right-4 md:bottom-6 md:right-6 z-[9999] bg-white/10 hover:bg-white/20 backdrop-blur-md p-3 rounded-full shadow-lg border border-white/10 transition-all text-white"
>
<Settings className="w-6 h-6 animate-spin-slow" />
</button>
{isOpen && (
<div className="fixed inset-0 z-[101] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
/>
<div className="relative bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-sm overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
<div className="flex items-center justify-between p-4 border-b border-white/5">
<h2 className="text-lg font-bold text-white">Appearance</h2>
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-white/10 rounded-full transition-colors text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-400 mb-3 uppercase tracking-wider">Choose Theme</h3>
<div className="space-y-2">
{themes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={`w-full flex items-center justify-between p-4 rounded-xl border transition-all ${currentTheme === theme.id
? 'bg-white/10 border-white/20'
: 'bg-transparent border-white/5 hover:bg-white/5'
}`}
>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg shadow-inner flex items-center justify-center font-bold text-white text-xs"
style={{ backgroundColor: theme.id === 'netflix' ? '#000' : '#111' }}
>
<span style={{ color: theme.color }}>
{theme.name.charAt(0)}
</span>
</div>
<span className="font-medium text-white">{theme.name}</span>
</div>
{currentTheme === theme.id && (
<div className="bg-green-500 rounded-full p-1">
<Check className="w-3 h-3 text-white" />
</div>
)}
</button>
))}
</div>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3">
<p className="text-xs text-blue-200 text-center">
Switching themes completely changes the layout and browsing experience.
</p>
</div>
</div>
</div>
</div>
)}
</>
);
};

View file

@ -1,43 +0,0 @@
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<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [currentTheme, setCurrentTheme] = useState<ThemeName>(() => {
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 (
<ThemeContext.Provider value={{ currentTheme, setTheme: setCurrentTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View file

@ -1,21 +1,7 @@
import { useTheme } from '../context/ThemeContext';
import { netflixTheme } from '../themes/netflix';
import { appleTheme } from '../themes/apple';
import { defaultTheme } from '../themes/default'; import { defaultTheme } from '../themes/default';
const themes = {
default: defaultTheme,
netflix: netflixTheme,
apple: appleTheme,
};
const Home = () => { const Home = () => {
const { currentTheme } = useTheme(); const ThemeHome = defaultTheme.components.Home;
// Dynamically select the Home component based on the current theme
const ActiveTheme = themes[currentTheme];
const ThemeHome = ActiveTheme.components.Home;
return <ThemeHome />; return <ThemeHome />;
}; };

View file

@ -1,22 +1,10 @@
import { useTheme } from '../context/ThemeContext';
import { netflixTheme } from '../themes/netflix';
import { appleTheme } from '../themes/apple';
import { useMyList } from '../hooks/useMyList'; import { useMyList } from '../hooks/useMyList';
import { SettingsPanel } from '../components/SettingsPanel';
import { defaultTheme } from '../themes/default'; import { defaultTheme } from '../themes/default';
const themes = {
netflix: netflixTheme,
apple: appleTheme,
default: defaultTheme,
};
const MyList = () => { const MyList = () => {
const { currentTheme } = useTheme();
const { savedMovies, watchHistory } = useMyList(); const { savedMovies, watchHistory } = useMyList();
const ActiveTheme = themes[currentTheme]; const { Layout, MovieGrid } = defaultTheme.components;
const { Layout, MovieGrid } = ActiveTheme.components;
return ( return (
<Layout> <Layout>
@ -38,7 +26,6 @@ const MyList = () => {
</div> </div>
)} )}
</div> </div>
<SettingsPanel />
</Layout> </Layout>
); );
}; };

View file

@ -1,21 +1,11 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router-dom'; 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 { useMyList } from '../hooks/useMyList';
import { defaultTheme } from '../themes/default'; import { defaultTheme } from '../themes/default';
const themes = {
netflix: netflixTheme,
apple: appleTheme,
default: defaultTheme,
};
const Watch = () => { const Watch = () => {
const { slug, episode } = useParams(); const { slug, episode } = useParams();
const { currentTheme } = useTheme();
const { addToHistory } = useMyList(); const { addToHistory } = useMyList();
// Fetch movie detail to get info for history // Fetch movie detail to get info for history
@ -48,9 +38,7 @@ const Watch = () => {
fetchDetail(); fetchDetail();
}, [slug]); }, [slug]);
// Select the current theme components const { WatchPage } = defaultTheme.components;
const ActiveTheme = themes[currentTheme];
const { WatchPage } = ActiveTheme.components;
return <WatchPage slug={slug || ''} episode={episode || '1'} />; return <WatchPage slug={slug || ''} episode={episode || '1'} />;
}; };

View file

@ -1,15 +0,0 @@
import { Layout } from './Layout';
import { HomeContent } from '../../components/HomeContent';
import { SettingsPanel } from '../../components/SettingsPanel';
export const AppleHome = () => {
return (
<Layout>
{/* Apple Theme usually has a dark gradient header, but HomeContent handles general layout */}
<div className="min-h-screen bg-black">
<HomeContent topPadding="pt-24" />
</div>
<SettingsPanel />
</Layout>
);
};

View file

@ -1,41 +0,0 @@
import type { Movie } from '../../types';
import { Play } from 'lucide-react';
export const Card = ({ movie }: { movie: Movie }) => {
return (
<div className="group flex flex-col gap-3 cursor-pointer">
<a href={`/watch/${movie.slug}`} className="relative">
<div className="aspect-[2/3] relative rounded-xl overflow-hidden shadow-lg group-hover:shadow-2xl transition-all duration-300">
<img
src={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail)}&w=500&output=webp`}
alt={movie.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<div className="bg-white/90 text-black rounded-full p-4 transform scale-50 group-hover:scale-100 transition-all duration-300 shadow-xl">
<Play className="w-6 h-6 fill-current" />
</div>
</div>
{/* Glass Badge */}
{movie.quality && (
<div className="absolute bottom-3 right-3 bg-white/10 backdrop-blur-md px-2 py-1 rounded-md text-[10px] text-white/90 font-medium border border-white/10">
{movie.quality}
</div>
)}
</div>
</a>
<div className="px-1 space-y-1">
<h3 className="font-semibold text-white/90 text-[15px] leading-tight truncate group-hover:text-white transition-colors">
{movie.title}
</h3>
<p className="text-white/40 text-xs font-medium truncate">
{movie.original_title || movie.year || '2024'}
</p>
</div>
</div>
);
};

View file

@ -1,86 +0,0 @@
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 (
<div className="relative h-[85vh] w-full overflow-hidden group">
<div className="absolute inset-0 scale-105 transition-transform duration-[10000ms] ease-linear">
<img
key={movie.id}
src={`https://wsrv.nl/?url=${encodeURIComponent(movie.backdrop || movie.thumbnail)}&w=1600&output=webp`}
alt={movie.title}
className="w-full h-full object-cover animate-fade-in"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-black/30" />
<div className="absolute inset-0 bg-gradient-to-r from-black/60 via-transparent to-transparent" />
</div>
<div className="absolute bottom-0 left-0 w-full p-8 md:p-16 lg:p-24 pb-20 z-10">
<div className="max-w-3xl space-y-6">
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-full animate-slide-up">
<span className="text-[10px] font-bold tracking-widest uppercase text-white/90">Premiere</span>
</div>
<h1 className="text-5xl md:text-7xl font-bold text-white tracking-tight drop-shadow-[0_2px_10px_rgba(0,0,0,0.5)] line-clamp-2 animate-slide-up" style={{ animationDelay: '100ms' }}>
{movie.title}
</h1>
{movie.original_title && (
<p className="text-xl text-white/70 font-medium animate-slide-up" style={{ animationDelay: '200ms' }}>{movie.original_title}</p>
)}
<div className="flex items-center gap-4 pt-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
<a
href={`/watch/${movie.slug}`}
className="bg-white text-black px-8 py-3.5 rounded-full font-bold text-sm tracking-wide hover:scale-105 transition-transform duration-200 flex items-center gap-2"
>
<Play className="w-4 h-4 fill-current" />
Play
</a>
<button
onClick={toggleList}
className="bg-white/10 backdrop-blur-md text-white px-8 py-3.5 rounded-full font-bold text-sm tracking-wide border border-white/20 hover:bg-white/20 transition-colors flex items-center gap-2"
>
{saved ? <Check className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
{saved ? 'In Up Next' : 'Add to Up Next'}
</button>
</div>
</div>
</div>
{/* Carousel Dots */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex gap-3 z-20">
{movies.map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`w-2 h-2 rounded-full transition-all ${i === index ? 'bg-white w-4' : 'bg-white/30 hover:bg-white/50'}`}
/>
))}
</div>
</div>
);
};

View file

@ -1,172 +0,0 @@
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 (
<div className="min-h-screen bg-[#000000] text-white selection:bg-white/20">
{/* Glass Navbar */}
<nav className={`fixed top-0 w-full z-50 transition-all duration-500 ${scrolled || isSearchOpen
? 'bg-[#1a1a1a]/90 backdrop-blur-xl border-b border-white/5'
: 'bg-gradient-to-b from-black/80 to-transparent'
}`}>
<div className="max-w-[1600px] mx-auto px-6 lg:px-12 h-16 flex items-center justify-between">
<div className="flex items-center gap-12">
<Link to="/" className="text-white hover:opacity-80 transition-opacity">
{/* Mock Apple Logo */}
<div className="flex items-center gap-1 font-semibold tracking-tight text-xl">
<Apple className="w-5 h-5 mb-1" />
<span>TV+</span>
</div>
</Link>
<div className="hidden md:flex items-center gap-8">
<Link to="/" className="text-sm font-medium text-white/90 hover:text-white transition-colors">Home</Link>
{CATEGORIES.map((item) => (
<Link
key={item.id}
to={item.path}
className="text-sm font-medium text-white/70 hover:text-white transition-colors"
>
{item.name}
</Link>
))}
</div>
</div>
<div className="flex items-center gap-6">
{/* Install App Button (PC/Tablet) */}
<a
href="/streamflow-tv.apk"
download="streamflow-tv.apk"
className="hidden lg:flex items-center gap-2 px-5 py-2.5 bg-white text-black hover:bg-white/90 text-sm font-bold rounded-full transition-all duration-300 shadow-xl shadow-white/5 active:scale-95"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="w-4 h-4"
>
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
<polyline points="17 2 12 7 7 2" />
</svg>
<span>TV APP</span>
</a>
<div className={`relative group flex items-center transition-all duration-300 ${isSearchOpen ? 'w-64 bg-white/10 rounded-lg px-2' : 'w-8'}`}>
<Search
className="w-4 h-4 text-white/70 group-hover:text-white transition-colors cursor-pointer"
onClick={() => setIsSearchOpen(true)}
/>
{isSearchOpen && (
<form onSubmit={handleSearch} className="flex-1">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search movies..."
className="w-full bg-transparent border-none outline-none focus:outline-none focus:ring-0 text-white text-sm placeholder:text-gray-400 ml-2 h-8"
autoFocus
onBlur={() => !searchQuery && setIsSearchOpen(false)}
/>
</form>
)}
</div>
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-purple-500 p-[1px]">
<div className="w-full h-full rounded-full bg-black flex items-center justify-center text-xs font-bold">
K
</div>
</div>
</div>
</div>
</nav>
{/* Mobile Bottom Nav */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-[#161616]/80 backdrop-blur-2xl border-t border-white/5 pb-safe">
<div className="flex items-center justify-around h-20 px-2 pb-2">
<Link to="/" className={`flex flex-col items-center gap-1.5 p-2 transition-colors ${location.pathname === '/' ? 'text-white' : 'text-white/40 hover:text-white/70'}`}>
<Home className={`w-6 h-6 ${location.pathname === '/' ? 'fill-current' : ''}`} strokeWidth={location.pathname === '/' ? 2.5 : 2} />
<span className="text-[10px] font-medium tracking-wide">Home</span>
</Link>
{CATEGORIES.slice(0, 3).map((item) => {
const getCategoryIcon = (id: string) => {
switch (id) {
case 'phim-le': return Film;
case 'phim-bo': return Tv; // Series implies TV
case 'hoat-hinh': return Sparkles; // Animation
case 'tv-shows': return MonitorPlay;
default: return Film;
}
};
const Icon = getCategoryIcon(item.id);
const isActive = location.pathname === item.path;
return (
<Link
key={item.id}
to={item.path}
className={`flex flex-col items-center gap-1.5 p-2 transition-colors ${isActive ? 'text-white' : 'text-white/40 hover:text-white/70'}`}
>
<Icon className={`w-6 h-6 ${isActive ? 'fill-current' : ''}`} strokeWidth={isActive ? 2.5 : 2} />
<span className="text-[10px] font-medium tracking-wide">{item.name}</span>
</Link>
);
})}
{/* APK Download in Mobile Nav */}
<a
href="/streamflow-tv.apk"
download="streamflow-tv.apk"
className="flex flex-col items-center gap-1.5 p-2 text-white animate-pulse"
>
<div className="p-1 rounded bg-white text-black">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="w-5 h-5"
>
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
<polyline points="17 2 12 7 7 2" />
</svg>
</div>
<span className="text-[10px] font-bold tracking-wide">TV APP</span>
</a>
</div>
</nav>
<main className="w-full pb-20 md:pb-0">
{children}
</main>
</div>
);
};

View file

@ -1,32 +0,0 @@
import type { Movie } from '../../types';
import { Card } from './Card';
export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => {
if (loading) {
return (
<div className="px-6 md:px-16 pt-8 pb-16">
{title && <h2 className="text-2xl font-bold mb-6 text-white/90">{title}</h2>}
<div className="grid grid-cols-2 min-[450px]:grid-cols-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-x-6 gap-y-10">
{[...Array(10)].map((_, i) => (
<div key={i} className="aspect-[2/3] bg-white/5 rounded-2xl animate-pulse" />
))}
</div>
</div>
);
}
return (
<div className="px-6 md:px-16 pt-8 pb-16">
<div className="flex items-baseline justify-between mb-6">
{title && <h2 className="text-2xl font-bold text-white/90">{title}</h2>}
<button className="text-blue-400 text-sm font-medium hover:text-blue-300 transition-colors">See All</button>
</div>
<div className="grid grid-cols-2 min-[450px]:grid-cols-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-x-8 gap-y-12">
{movies.map((movie) => (
<Card key={movie.id} movie={movie} />
))}
</div>
</div>
);
};

View file

@ -1,201 +0,0 @@
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);
const [selectedServer, setSelectedServer] = useState<string>('');
if (!movie) return <div className="text-white p-10">Loading...</div>;
// Group episodes by server
const episodesByServer = movie?.episodes?.reduce((acc, ep) => {
const server = ep.server_name || 'Default';
if (!acc[server]) acc[server] = [];
acc[server].push(ep);
return acc;
}, {} as Record<string, typeof movie.episodes>) || {};
const serverNames = Object.keys(episodesByServer);
// Initialize selected server
if (serverNames.length > 0 && !selectedServer) {
const defaultServer = serverNames.find(s => s.toLowerCase().includes('vietsub #1')) || serverNames[0];
setSelectedServer(defaultServer);
}
const currentServerEpisodes = episodesByServer[selectedServer] || [];
const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20);
return (
<div className="min-h-screen bg-black text-white selection:bg-white/20 font-sans">
{/* Navigation */}
<div className={`fixed top-0 left-0 z-50 p-4 md:p-6 transition-opacity duration-300 ${loading ? 'opacity-0' : 'opacity-100 hover:opacity-100'}`}>
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 text-white/70 hover:text-white transition-colors bg-black/40 backdrop-blur-xl border border-white/10 px-4 py-2 rounded-full"
>
<ArrowLeft className="w-5 h-5" />
<span className="font-medium text-sm hidden md:inline">Main Menu</span>
</button>
</div>
<div className="flex flex-col pb-20">
{/* Player Section - Sticky on larger screens for cinema feel */}
<div className="sticky top-0 z-40 w-full aspect-video md:h-[75vh] bg-black relative shadow-2xl">
{loading && (
<div className="absolute inset-0 flex items-center justify-center z-20">
<div className="w-10 h-10 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
</div>
)}
{(() => {
const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode);
if (!activeEpisode?.url) {
return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-3xl">
<div className="text-center space-y-4 px-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-white/5 backdrop-blur-md mb-2 border border-white/10">
<div className="w-2 h-2 bg-white rounded-full animate-pulse" />
</div>
<h2 className="text-xl md:text-2xl font-bold tracking-tight">Processing Content</h2>
<p className="text-white/60 text-sm max-w-xs mx-auto">
This title is currently being prepared for streaming.
</p>
</div>
{/* Subtle Background */}
<div
className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-3xl"
style={{
backgroundImage: `url(https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=400&output=webp)`
}}
/>
</div>
);
}
return (
<video
key={activeEpisode.url}
ref={videoRef}
controls
className="w-full h-full object-contain bg-black"
poster={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=1600&output=webp`}
/>
);
})()}
</div>
{/* Content Section - Scrolls over the bottom of the player if sticky, or just below */}
{/* Content Section - Scrolls over the bottom of the player if sticky, or just below */}
<div className="relative z-50 bg-black rounded-t-3xl border-t border-white/10 shadow-[0_-10px_40px_rgba(0,0,0,0.8)] px-4 md:px-12 py-8 md:py-12 max-w-[1800px] mx-auto w-full space-y-12 min-h-screen">
{/* Movie Info */}
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<h1 className="text-3xl md:text-5xl font-bold tracking-tight text-white">{movie.title}</h1>
<div className="flex items-center gap-3 text-sm text-gray-400 font-medium">
<span className="px-2 py-0.5 border border-white/20 rounded text-xs uppercase">HD</span>
<span>{movie.year || '2024'}</span>
<span>{movie.episodes?.length || 0} Episodes</span>
</div>
</div>
<p className="text-gray-400 text-base md:text-lg max-w-4xl leading-relaxed">{movie.description}</p>
</div>
{/* Episodes Grid */}
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-white/10 pb-4">
<div className="flex flex-wrap items-center gap-6">
<h3 className="text-lg font-bold">Episodes</h3>
{/* Server Selector */}
{serverNames.length > 1 && (
<div className="flex flex-wrap gap-2">
{serverNames.map(server => (
<button
key={server}
onClick={() => setSelectedServer(server)}
className={`px-3 py-1 text-xs font-medium rounded-full transition-all ${selectedServer === server
? 'bg-white text-black'
: 'bg-white/5 text-white/50 hover:bg-white/10 hover:text-white'
}`}
>
{server}
</button>
))}
</div>
)}
</div>
<span className="text-sm text-gray-500">{currentServerEpisodes.length} available</span>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 gap-2">
{visibleEpisodes.map((ep) => (
<button
key={`${ep.number}-${selectedServer}`}
onClick={() => {
setCurrentEpisode(ep.number);
navigate(`/watch/${slug}/${ep.number}`);
}}
className={`group relative py-2 rounded-lg flex items-center justify-center transition-all duration-300 border ${currentEpisode === ep.number
? 'bg-white text-black border-white shadow-[0_0_15px_rgba(255,255,255,0.2)]'
: 'bg-zinc-900/50 hover:bg-zinc-800 text-white border-white/5 hover:border-white/20'
}`}
>
<span className="font-bold text-sm">
{ep.number}
</span>
{currentEpisode === ep.number && (
<div className="absolute top-1 right-1">
<Play className="w-2.5 h-2.5 fill-current" />
</div>
)}
</button>
))}
</div>
{currentServerEpisodes.length > 20 && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full py-4 flex items-center justify-center gap-2 text-sm font-medium text-gray-400 hover:text-white transition-colors bg-zinc-900/50 hover:bg-zinc-900 rounded-xl"
>
{expanded ? (
<>Show Less <ChevronUp className="w-4 h-4" /></>
) : (
<>Show All Episodes <ChevronDown className="w-4 h-4" /></>
)}
</button>
)}
</div>
{/* Related Categories */}
<div className="space-y-12 pt-12 border-t border-white/10">
<div className="space-y-4">
<h3 className="text-xl font-bold">More Like This</h3>
<MovieRow title="" category={movie.category || 'phim-le'} limit={10} key="related" />
</div>
<div className="space-y-4">
<h3 className="text-xl font-bold">Trending Now</h3>
<MovieRow title="" category="home" limit={10} key="trending" />
</div>
<div className="space-y-4">
<h3 className="text-xl font-bold">Top Movies</h3>
<MovieRow title="" category="phim-le" limit={10} key="top" />
</div>
<div className="space-y-4">
<h3 className="text-xl font-bold">Animation</h3>
<MovieRow title="" category="hoat-hinh" limit={10} key="anim" />
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -1,25 +0,0 @@
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
},
};

View file

@ -1,6 +1,5 @@
import Navbar from '../../components/Navbar'; import Navbar from '../../components/Navbar';
import { HomeContent } from '../../components/HomeContent'; import { HomeContent } from '../../components/HomeContent';
import { SettingsPanel } from '../../components/SettingsPanel';
export const DefaultHome = () => { export const DefaultHome = () => {
return ( return (
@ -9,7 +8,6 @@ export const DefaultHome = () => {
<div className="pt-16"> <div className="pt-16">
<HomeContent topPadding="pt-8 md:pt-12" /> <HomeContent topPadding="pt-8 md:pt-12" />
</div> </div>
<SettingsPanel />
</div> </div>
); );
}; };

View file

@ -1,10 +1,10 @@
import type { Theme } from '../../types/Theme'; import type { Theme } from '../../types/Theme';
import { DefaultHome } from './DefaultHome'; import { DefaultHome } from './DefaultHome';
import { Hero } from '../../components/Hero'; import { Hero } from '../../components/Hero';
import { MovieGrid } from '../netflix/MovieGrid'; import { MovieGrid } from '../../components/MovieGrid';
import { Card } from '../netflix/Card'; import { Card } from '../../components/Card';
import { WatchPage } from './WatchPage'; // Use local StreamFlow WatchPage import { WatchPage } from './WatchPage'; // Use local StreamFlow WatchPage
import { Layout } from '../netflix/Layout'; // Fallback layout if needed, but Home handles it import { Layout } from '../../components/Layout'; // Fallback layout if needed, but Home handles it
export const defaultTheme: Theme = { export const defaultTheme: Theme = {
name: 'default', name: 'default',

View file

@ -1,6 +0,0 @@
import { MovieCard } from '../../components/MovieCard';
import type { Movie } from '../../types';
export const Card = ({ movie }: { movie: Movie }) => {
return <MovieCard movie={movie} />;
};

View file

@ -1,87 +0,0 @@
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 (
<div className="relative h-[85vh] w-full mr-4 overflow-hidden group">
<div className="absolute inset-0 transition-opacity duration-1000 ease-in-out">
<img
key={movie.id}
src={`https://wsrv.nl/?url=${encodeURIComponent(movie.backdrop || movie.thumbnail)}&w=1600&output=webp`}
alt={movie.title}
className="w-full h-full object-cover mask-image-gradient animate-fade-in"
/>
<div className="absolute inset-0 bg-gradient-to-r from-[#141414] via-[#141414]/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" />
</div>
<div className="absolute inset-0 flex items-center px-4 md:px-12 z-10">
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-2 mb-4 animate-slide-up">
<span className="bg-red-600 text-white text-xs font-bold px-2 py-1 rounded-sm">TOP 10 TODAY</span>
<span className="text-gray-300 text-sm font-medium tracking-widest uppercase">#{index + 1} in Movies</span>
</div>
<h1 className="text-4xl md:text-6xl font-bold text-white leading-tight drop-shadow-xl line-clamp-2 animate-slide-up" style={{ animationDelay: '100ms' }}>
{movie.title}
</h1>
{movie.original_title && (
<p className="text-xl text-gray-300 italic animate-slide-up" style={{ animationDelay: '200ms' }}>{movie.original_title}</p>
)}
<div className="flex items-center gap-3 pt-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
<a
href={`/watch/${movie.slug}`}
className="flex items-center gap-2 bg-white text-black px-8 py-3 rounded font-bold hover:bg-opacity-90 transition-colors"
>
<Play className="w-6 h-6 fill-current" />
Play
</a>
<button
onClick={toggleList}
className="flex items-center gap-2 bg-gray-500/70 text-white px-8 py-3 rounded font-bold backdrop-blur-sm hover:bg-gray-500/50 transition-colors"
>
{saved ? <Check className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
{saved ? 'My List' : 'My List'}
</button>
</div>
</div>
</div>
{/* Indicators */}
<div className="absolute right-12 bottom-1/3 flex flex-col gap-2 z-20">
{movies.map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`w-2 h-2 rounded-full transition-all ${i === index ? 'bg-white scale-125' : 'bg-gray-500 hover:bg-gray-400'}`}
/>
))}
</div>
</div>
);
};

View file

@ -1,14 +0,0 @@
import { Layout } from './Layout';
import { HomeContent } from '../../components/HomeContent';
import { SettingsPanel } from '../../components/SettingsPanel';
export const NetflixHome = () => {
return (
<Layout>
<div className="w-full min-h-screen bg-black">
<HomeContent topPadding="pt-8" />
</div>
<SettingsPanel />
</Layout>
);
};

View file

@ -1,186 +0,0 @@
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);
const [selectedServer, setSelectedServer] = useState<string>('');
// Group episodes by server
const episodesByServer = movie?.episodes?.reduce((acc, ep) => {
const server = ep.server_name || 'Default';
if (!acc[server]) acc[server] = [];
acc[server].push(ep);
return acc;
}, {} as Record<string, typeof movie.episodes>) || {};
const serverNames = Object.keys(episodesByServer);
// Initialize selected server
if (serverNames.length > 0 && !selectedServer) {
// Prefer "Ophim" or "Vietsub #1" if available, else first
const defaultServer = serverNames.find(s => s.includes('Ophim')) || serverNames[0];
setSelectedServer(defaultServer);
}
const currentServerEpisodes = episodesByServer[selectedServer] || [];
const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20);
if (!movie) return <div className="text-white p-10">Loading...</div>;
return (
<div className="min-h-screen bg-[#141414] text-white font-sans selection:bg-red-600 selection:text-white pb-20">
{/* Back Navigation */}
<div className="fixed top-0 left-0 right-0 z-50 p-4 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
<button
onClick={() => navigate('/')}
className="pointer-events-auto flex items-center gap-2 px-4 py-2 bg-black/50 hover:bg-white/20 backdrop-blur-md rounded-full transition-all group"
>
<ArrowLeft className="w-5 h-5 text-white group-hover:-translate-x-1 transition-transform" />
<span className="font-medium">Back to Home</span>
</button>
</div>
{/* 1. Cinema Player Section */}
<div className="w-full h-[50vh] md:h-[80vh] bg-black relative shadow-2xl z-40">
{loading && (
<div className="absolute inset-0 flex items-center justify-center z-20">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-red-600"></div>
</div>
)}
{(() => {
const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode);
if (!activeEpisode?.url) {
return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-black/90">
<div className="text-center px-6 max-w-lg">
<h2 className="text-3xl font-bold text-white mb-4">Coming Soon</h2>
<p className="text-gray-400 text-lg mb-6">
We're busy uploading the best quality version of this movie.
</p>
</div>
<div
className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-2xl grayscale"
style={{
backgroundImage: `url(https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=400&output=webp)`
}}
/>
</div>
);
}
return (
<video
ref={videoRef}
controls
className="w-full h-full max-h-screen object-contain"
poster={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=1280&output=webp`}
/>
);
})()}
</div>
{/* 2. Content Info & Rows */}
<div className="max-w-[1600px] mx-auto px-4 md:px-12 py-8 space-y-12">
{/* Glass Info Card */}
<div className="bg-[#181818]/90 backdrop-blur-xl rounded-xl p-6 md:p-10 shadow-2xl border border-white/5 mx-2 md:mx-0">
<h1 className="text-3xl md:text-5xl font-bold mb-4 tracking-tight">{movie.title}</h1>
{/* Meta Tags */}
<div className="flex items-center gap-4 text-sm md:text-base mb-6">
<span className="text-green-500 font-bold">98% Match</span>
<span className="text-gray-400">{movie.year || '2024'}</span>
<span className="border border-gray-600 px-2 py-0.5 rounded text-xs bg-black/40">HD</span>
<span className="text-gray-400">{movie.original_title}</span>
</div>
<div
className="text-gray-300 leading-relaxed max-w-4xl text-base md:text-lg"
dangerouslySetInnerHTML={{ __html: movie.description }}
/>
</div>
{/* Episodes Section - Compact Grid */}
{currentServerEpisodes.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
<h3 className="text-2xl font-bold border-l-4 border-red-600 pl-4">Episodes</h3>
{/* Server Selector */}
{serverNames.length > 1 && (
<div className="flex gap-2">
{serverNames.map(server => (
<button
key={server}
onClick={() => setSelectedServer(server)}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${selectedServer === server
? 'bg-red-600 text-white'
: 'bg-[#333] text-gray-400 hover:bg-[#444]'
}`}
>
{server}
</button>
))}
</div>
)}
</div>
<div className="text-gray-400 text-sm font-medium">{currentServerEpisodes.length} Items</div>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 gap-2">
{visibleEpisodes.map((ep) => (
<button
key={`${selectedServer}-${ep.number}`}
onClick={() => {
setCurrentEpisode(ep.number);
navigate(`/watch/${slug}/${ep.number}`);
}}
className={`group relative py-2 rounded-md overflow-hidden border-2 transition-all ${currentEpisode === ep.number ? 'border-red-600 bg-red-900/10' : 'border-transparent hover:border-white/40 bg-[#222]'}`}
>
<div className="flex items-center justify-center">
<span className={`font-bold text-sm ${currentEpisode === ep.number ? 'text-red-500' : 'text-gray-400 group-hover:text-white'}`}>
{ep.number}
</span>
</div>
{currentEpisode === ep.number && (
<div className="absolute top-1 right-1">
<Play className="w-2.5 h-2.5 text-red-500 fill-current" />
</div>
)}
</button>
))}
</div>
{currentServerEpisodes.length > 20 && (
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm font-medium text-gray-400 hover:text-white transition-colors mt-4"
>
{expanded ? (
<>Show Less <ChevronUp className="w-4 h-4" /></>
) : (
<>Show All Episodes <ChevronDown className="w-4 h-4" /></>
)}
</button>
)}
</div>
)}
{/* Related Content Section */}
<div className="space-y-12 pt-8 border-t border-white/10">
<MovieRow title="More Like This" category={movie.category || 'phim-le'} limit={10} key={`related-${movie.slug}`} />
<MovieRow title="New Releases" category="home" limit={10} key="trending" />
<MovieRow title="Top Movies" category="phim-le" limit={10} key="top-movies" />
<MovieRow title="Animation" category="hoat-hinh" limit={10} key="animation" />
</div>
</div>
</div>
);
};

View file

@ -1,25 +0,0 @@
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
},
};

View file

@ -1,51 +0,0 @@
# Streamflow Dev Start Script (Auto-Restart)
Write-Host "=============================" -ForegroundColor Cyan
Write-Host " Streamflow Dev Launcher " -ForegroundColor Cyan
Write-Host "=============================" -ForegroundColor Cyan
$BackendPort = 8000
$FrontendPort = 5173
# Helper function to kill processes on a port
function Kill-Port($port) {
echo "Checking port $port..."
$connection = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue
if ($connection) {
$pidNum = $connection.OwningProcess
Write-Host " -> Killing process $pidNum on port $port" -ForegroundColor Yellow
Stop-Process -Id $pidNum -Force -ErrorAction SilentlyContinue
} else {
Write-Host " -> Port $port is free." -ForegroundColor Green
}
}
# 1. Cleanup
Write-Host "`n[1/4] Cleaning up existing processes..." -ForegroundColor White
Kill-Port $BackendPort
Kill-Port $FrontendPort
# 2. Start Backend
Write-Host "`n[2/4] Starting Backend (Go)..." -ForegroundColor White
$backendProcess = Start-Process -FilePath "go" -ArgumentList "run cmd/server/main.go" -WorkingDirectory "$PSScriptRoot\backend" -PassThru -NoNewWindow:$false
Write-Host " -> Backend started (PID: $($backendProcess.Id))" -ForegroundColor Green
# 3. Start Frontend
Write-Host "`n[3/4] Starting Frontend (Vite)..." -ForegroundColor White
# Use npm.cmd for Windows compatibility
$frontendProcess = Start-Process -FilePath "npm.cmd" -ArgumentList "run dev" -WorkingDirectory "$PSScriptRoot\frontend-react" -PassThru -NoNewWindow:$false
Write-Host " -> Frontend started (PID: $($frontendProcess.Id))" -ForegroundColor Green
# 4. Launch Browser
Write-Host "`n[4/4] Waiting for services..." -ForegroundColor White
for ($i = 5; $i -gt 0; $i--) {
Write-Host " -> Launching in $i seconds..." -NoNewline
Start-Sleep -Seconds 1
Write-Host "`r" -NoNewline
}
Write-Host "`n -> Opening http://localhost:$FrontendPort" -ForegroundColor Cyan
Start-Process "http://localhost:$FrontendPort"
Write-Host "`nAll systems go! Close the pop-up windows to stop the servers." -ForegroundColor Magenta
Start-Sleep -Seconds 3