feat: dockerize app, add brand assets, implement wake lock

This commit is contained in:
vndangkhoa 2026-02-01 19:13:15 +07:00
parent 32a5ebdff2
commit f2e7467abf
7 changed files with 373 additions and 435 deletions

View file

@ -1,56 +1,49 @@
# ===========================
# Stage 1: Frontend Build
# ===========================
FROM node:20-alpine AS frontend-builder
# Stage 1: Build Image (Frontend)
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend-react/package*.json ./
RUN npm ci
RUN npm install
COPY frontend-react/ .
RUN npm run build
# ===========================
# Stage 2: Backend Build
# ===========================
FROM golang:1.22-alpine AS backend-builder
# Stage 2: Build Image (Backend)
FROM golang:1.23-alpine AS backend-builder
WORKDIR /app/backend
# Install necessary build tools for CGO (SQLite)
# Install build dependencies
RUN apk add --no-cache gcc musl-dev
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
# Build Linux binary
RUN CGO_ENABLED=1 GOOS=linux go build -o server ./cmd/server/main.go
# Build static binary for Linux amd64
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go
# ===========================
# Stage 3: Runtime
# ===========================
FROM python:3.11-alpine
# Stage 3: Final Image
FROM alpine:latest
WORKDIR /app
# Install Runtime Dependencies
# - ffmpeg: for yt-dlp media handling
# - yt-dlp: via pip
# - ca-certificates: for HTTPS
RUN apk add --no-cache ffmpeg ca-certificates && \
pip install --no-cache-dir yt-dlp
# Install runtime dependencies (sqlite)
RUN apk add --no-cache sqlite ca-certificates tzdata
# Create non-root user
RUN addgroup -S streamflow && adduser -S streamflow -G streamflow
# Copy backend binary
COPY --from=backend-builder /app/backend/server .
# Copy Frontend Build
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
# Copy frontend build to the expected static directory
# The backend expects ../frontend-react/dist relative to itself, or we configure it.
# Let's align with the standard deployment structure: /app/server and /app/dist
COPY --from=frontend-builder /app/frontend/dist ./dist
# Copy Backend Binary
COPY --from=backend-builder /app/backend/server /app/server
# Create data directory
RUN mkdir -p data
# Setup Permissions
RUN chown -R streamflow:streamflow /app
USER streamflow
# Environment variables
ENV PORT=8000
ENV DATABASE_URL=sqlite:///app/data/streamflow.db
ENV GIN_MODE=release
# Expose port
EXPOSE 8000
CMD ["/app/server"]
# Start server
CMD ["./server"]

View file

@ -1,18 +1,19 @@
version: '3.8'
services:
app:
streamflow:
build: .
image: streamflow:v2
image: streamflow:latest
container_name: streamflow
platform: linux/amd64
ports:
- "8000:8000"
- "3478:8000"
environment:
- DATABASE_URL=sqlite:///./data/streamflow.db
- DATABASE_URL=sqlite:///app/data/streamflow.db
- TMDB_API_KEY=${TMDB_API_KEY}
volumes:
- streamflow_data:/app/data
- streamflow_cache:/app/cache
restart: unless-stopped
- ./data:/app/data
restart: always
volumes:
streamflow_data:

View file

@ -70,18 +70,73 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
hls.loadSource(source.stream_url);
hls.attachMedia(videoRef.current);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
videoRef.current?.play();
videoRef.current?.play().catch(() => { });
});
return () => {
hls.destroy();
};
} else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.current.src = source.stream_url;
videoRef.current.play();
videoRef.current.play().catch(() => { });
}
}
}, [source]);
// Wake Lock Logic (Prevent Screen Sleep)
useEffect(() => {
const video = videoRef.current;
let wakeLock: any = null;
const requestWakeLock = async () => {
try {
if ('wakeLock' in navigator) {
wakeLock = await (navigator as any).wakeLock.request('screen');
// console.log('Wake Lock active');
}
} catch (err) {
console.warn('Wake Lock failed:', err);
}
};
const releaseWakeLock = async () => {
if (wakeLock) {
try {
await wakeLock.release();
wakeLock = null;
// console.log('Wake Lock released');
} catch (err) {
console.warn('Wake Lock release failed:', err);
}
}
};
if (video) {
const onPlay = () => requestWakeLock();
const onPause = () => releaseWakeLock();
const onEnded = () => releaseWakeLock();
video.addEventListener('play', onPlay);
video.addEventListener('pause', onPause);
video.addEventListener('ended', onEnded);
// Re-acquire on visibility change if playing
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && !video.paused) {
requestWakeLock();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
video.removeEventListener('play', onPlay);
video.removeEventListener('pause', onPause);
video.removeEventListener('ended', onEnded);
document.removeEventListener('visibilitychange', onVisibilityChange);
releaseWakeLock();
};
}
}, [source]); // Re-run when source changes (new video loaded)
return {
movie,
source,

View file

@ -71,7 +71,8 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
</div>
{/* Content Section - Scrolls over the bottom of the player if sticky, or just below */}
<div className="relative z-50 bg-black -mt-4 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">
{/* 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">
@ -93,7 +94,7 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
<span className="text-sm text-gray-500">{episodes.length} available</span>
</div>
<div className="grid grid-cols-5 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{(expanded ? episodes : episodes.slice(0, 20)).map((ep) => (
<button
key={ep.number}

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Play, ChevronDown, ChevronUp } from 'lucide-react';
import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react';
import { useWatchMovie } from '../../hooks/useWatchMovie';
import MovieRow from '../../components/MovieRow';
@ -29,225 +29,134 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
const visibleEpisodes = expanded ? episodes : episodes.slice(0, 20);
return (
<div className="flex flex-col h-screen overflow-hidden bg-[#000000] text-white font-sans selection:bg-cyan-500/30">
{/* Header / Navigation Overlay */}
<div className="absolute top-0 left-0 right-0 p-6 z-50 flex items-center justify-between bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
<div className="min-h-screen bg-[#0a0a0a] text-white font-sans selection:bg-cyan-500/30 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 bg-white/10 backdrop-blur-md px-4 py-2 rounded-full hover:bg-white/20 transition-all border border-white/5 group"
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 border border-white/5"
>
<ArrowLeft className="w-5 h-5 text-gray-200 group-hover:-translate-x-1 transition-transform" />
<span className="font-medium text-sm">Back to Home</span>
</button>
</div>
{/* Main Layout Container */}
<div className="flex-1 flex flex-col-reverse lg:flex-row h-full overflow-hidden">
{/* Sidebar / Metadata Panel */}
<div className="w-full lg:w-[400px] h-[60vh] lg:h-full bg-[#141414] border-r border-white/5 overflow-y-auto custom-scrollbar flex flex-col z-30 shadow-2xl">
<div className="p-6 md:p-8 space-y-8 pb-20">
{/* Movie Header */}
<div className="space-y-4">
<h1 className="text-2xl md:text-3xl font-bold leading-tight text-white tracking-tight">{movie.title}</h1>
<div className="flex items-center flex-wrap gap-3 text-sm">
<span className="bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wider">
{movie.quality || 'HD'}
</span>
<span className="text-gray-400">{movie.year || '2024'}</span>
<span className="w-1 h-1 bg-gray-600 rounded-full" />
<span className="text-green-400 font-medium">98% Match</span>
<span className="w-1 h-1 bg-gray-600 rounded-full" />
<span className="text-gray-400 truncate max-w-[150px]">{movie.original_title}</span>
</div>
</div>
{/* Description */}
<div className="space-y-2">
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest">Synopsis</h3>
<div
className="text-gray-300 leading-relaxed text-sm font-light"
dangerouslySetInnerHTML={{ __html: movie.description }}
/>
</div>
{/* Episodes Grid */}
{episodes.length > 0 && (
<div className="space-y-4 pt-4 border-t border-white/5">
<div className="flex items-center justify-between">
<h3 className="flex items-center gap-2 text-lg font-bold text-white">
<Play className="w-5 h-5 text-cyan-500 fill-current" />
Episodes
</h3>
<span className="text-xs font-medium text-gray-500 bg-white/5 px-2 py-1 rounded-full">
{episodes.length} Items
</span>
</div>
<div className="grid grid-cols-5 lg:grid-cols-1 gap-2">
{visibleEpisodes.map((ep) => (
<button
key={ep.number}
onClick={() => {
setCurrentEpisode(ep.number);
navigate(`/watch/${slug}/${ep.number}`);
}}
className={`group relative flex items-center justify-center lg:justify-start gap-4 p-2 lg:p-4 rounded-xl transition-all duration-300 border ${currentEpisode === ep.number
? 'bg-gradient-to-r from-cyan-500/10 to-transparent border-cyan-500/20'
: 'bg-[#1a1a1a] border-transparent hover:bg-[#222] hover:border-white/10'
}`}
>
{/* Episode Number/Status */}
<div className="relative">
<div className={`w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center text-xs lg:text-sm font-bold transition-colors ${currentEpisode === ep.number ? 'bg-cyan-500 text-black' : 'bg-black/40 text-gray-500 group-hover:text-white'
}`}>
{currentEpisode === ep.number ? <Play className="w-3 h-3 lg:w-4 lg:h-4 fill-current ml-0.5" /> : ep.number}
</div>
</div>
{/* Episode Info - Show only on Desktop */}
<div className="hidden lg:block flex-1 text-left">
<h4 className={`font-medium text-sm transition-colors ${currentEpisode === ep.number ? 'text-cyan-400' : 'text-gray-200 group-hover:text-white'}`}>
{ep.title || `Episode ${ep.number}`}
</h4>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-gray-500 uppercase tracking-wider font-medium">Server VIP</span>
</div>
</div>
{/* Active Indicator - Desktop Only (rely on color for mobile) */}
{currentEpisode === ep.number && (
<div className="hidden lg:block absolute right-4 w-1.5 h-1.5 rounded-full bg-cyan-400 shadow-[0_0_10px_rgba(34,211,238,0.8)] animate-pulse" />
)}
</button>
))}
</div>
{episodes.length > 20 && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-2 py-3 bg-white/5 hover:bg-white/10 rounded-xl text-sm font-medium text-gray-300 hover:text-white transition-colors"
>
{expanded ? (
<>Show Less <ChevronUp className="w-4 h-4" /></>
) : (
<>Show All Episodes ({episodes.length}) <ChevronDown className="w-4 h-4" /></>
)}
</button>
)}
</div>
)}
{/* Related Content Section */}
<div className="py-6 border-t border-white/5 w-full space-y-8">
{/* More Like This */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white"> thể bạn sẽ thích</h3>
<MovieRow
title=""
category={movie.category || 'phim-le'}
limit={10}
key={`related-${movie.slug}`}
/>
</div>
{/* Trending */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">Phim Mới Cập Nhật</h3>
<MovieRow
title=""
category="home"
limit={10}
key="trending"
/>
</div>
{/* Top Movies */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">Top Phim Lẻ</h3>
<MovieRow
title=""
category="phim-le"
limit={10}
key="top-movies"
/>
</div>
{/* Top Series */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">Top Phim Bộ</h3>
<MovieRow
title=""
category="phim-bo"
limit={10}
key="top-series"
/>
</div>
{/* Animation */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">Hoạt Hình Hot</h3>
<MovieRow
title=""
category="hoat-hinh"
limit={10}
key="animation"
/>
</div>
{/* TV Shows */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">TV Shows</h3>
<MovieRow
title=""
category="tv-shows"
limit={10}
key="tv-shows"
/>
</div>
</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-16 w-16 border-4 border-cyan-500 border-t-transparent shadow-[0_0_20px_rgba(6,182,212,0.5)]"></div>
</div>
)}
{(() => {
const activeEpisode = movie.episodes?.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(${getImageUrl(movie.backdrop || movie.thumbnail, 400)})` }}
/>
</div>
);
}
return (
<video
ref={videoRef}
controls
className="w-full h-full max-h-screen object-contain"
poster={getImageUrl(movie.backdrop || movie.thumbnail, 1280)}
/>
);
})()}
</div>
{/* 2. Content Info & Rows */}
{/* 2. Content Info & Rows */}
<div className="max-w-[1600px] mx-auto px-4 md:px-12 py-8 md:py-12 space-y-12">
{/* Glass Info Card */}
<div className="bg-[#141414]/90 backdrop-blur-xl rounded-2xl 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="bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wider">
{movie.quality || 'HD'}
</span>
<span className="text-gray-400">{movie.year || '2024'}</span>
<span className="text-green-400 font-medium">98% Match</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 font-light"
dangerouslySetInnerHTML={{ __html: movie.description }}
/>
</div>
{/* Main Content Area (Player) */}
<div className="flex-1 relative flex items-center justify-center bg-black group/player z-10">
{/* Ambient Background Gradient behind player */}
<div className="absolute inset-0 bg-gradient-to-tr from-cyan-900/10 to-blue-900/10 opacity-50" />
{loading && (
<div className="absolute inset-0 flex items-center justify-center z-20 bg-black/50 backdrop-blur-sm">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-cyan-500 border-t-transparent shadow-[0_0_20px_rgba(6,182,212,0.5)]"></div>
{/* Episodes Section - Compact Grid */}
{episodes.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold border-l-4 border-cyan-500 pl-4">Episodes</h3>
<div className="text-gray-400 text-sm font-medium bg-white/5 px-3 py-1 rounded-full">{episodes.length} Items</div>
</div>
)}
{(() => {
const activeEpisode = movie.episodes?.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/80 backdrop-blur-md w-full h-full">
<div className="p-8 rounded-2xl bg-white/5 border border-white/10 text-center max-w-md mx-auto">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Play className="w-8 h-8 text-white/20 ml-1" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Coming Soon</h3>
<p className="text-gray-400 text-sm leading-relaxed">
The server is currently processing the movie "<strong>{movie.title}</strong>". Please check back later for the upload.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{visibleEpisodes.map((ep) => (
<button
key={ep.number}
onClick={() => {
setCurrentEpisode(ep.number);
navigate(`/watch/${slug}/${ep.number}`);
}}
className={`group relative aspect-video rounded-xl overflow-hidden border transition-all duration-300 ${currentEpisode === ep.number
? 'border-cyan-500 bg-cyan-950/30'
: 'border-transparent bg-[#1a1a1a] hover:bg-[#222] hover:border-white/10'
}`}
>
<div className="absolute inset-0 flex items-center justify-center">
<span className={`font-bold text-lg ${currentEpisode === ep.number ? 'text-cyan-400' : 'text-gray-500 group-hover:text-white'
}`}>
{ep.number}
</span>
</div>
{/* Ambient background from poster */}
<div className="absolute inset-0 -z-10 opacity-20 bg-cover bg-center blur-3xl" style={{ backgroundImage: `url(${getImageUrl(movie.backdrop || movie.thumbnail, 400)})` }} />
</div>
);
}
{currentEpisode === ep.number && (
<div className="absolute bottom-2 right-2 w-2 h-2 rounded-full bg-cyan-400 animate-pulse shadow-[0_0_10px_rgba(34,211,238,0.8)]" />
)}
</button>
))}
</div>
return (
<video
ref={videoRef}
controls
className="relative w-full h-full max-h-screen object-contain z-10 focus:outline-none"
poster={getImageUrl(movie.backdrop || movie.thumbnail, 1280)}
/>
);
})()}
{episodes.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 mx-auto"
>
{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/5">
<MovieRow title="Có thể bạn sẽ thích" category={movie.category || 'phim-le'} limit={10} key={`related-${movie.slug}`} />
<MovieRow title="Phim Mới Cập Nhật" category="home" limit={10} key="trending" />
<MovieRow title="Top Phim Lẻ" category="phim-le" limit={10} key="top-movies" />
<MovieRow title="Top Phim Bộ" category="phim-bo" limit={10} key="top-series" />
</div>
</div>
</div>

View file

@ -1,7 +1,7 @@
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';
@ -16,205 +16,133 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
const visibleEpisodes = expanded ? episodes : episodes.slice(0, 20);
return (
<Layout>
<div className="flex flex-col h-screen overflow-hidden bg-black text-white">
{/* Back Button Overlay */}
<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="absolute top-6 left-6 z-50 bg-black/50 p-2 rounded-full hover:bg-white/20 transition-colors"
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-6 h-6 text-white" />
<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>
<div className="flex-1 flex flex-col-reverse lg:flex-row h-full overflow-hidden">
{/* Sidebar / Metadata Panel */}
<div className="w-full lg:w-[400px] h-[60vh] lg:h-full bg-[#181818] border-r border-white/5 overflow-y-auto custom-scrollbar flex flex-col z-30 shadow-2xl">
<div className="p-6 md:p-8 space-y-8 pb-20">
{/* Movie Header */}
<div className="space-y-4">
<h1 className="text-2xl md:text-3xl font-bold leading-tight text-white tracking-tight">{movie.title}</h1>
<div className="flex items-center flex-wrap gap-3 text-sm">
<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-1.5 py-0.5 rounded text-xs">HD</span>
<span className="text-gray-400 truncate max-w-[150px]">{movie.original_title}</span>
{/* 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 = movie.episodes?.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>
);
}
<p className="text-gray-300 leading-relaxed text-sm">
{movie.description}
</p>
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>
{/* Episodes Grid */}
{episodes.length > 0 && (
<div className="space-y-4 pt-4 border-t border-white/10">
<div className="flex items-center justify-between">
<h3 className="font-bold text-lg text-white">Episodes</h3>
<span className="text-xs text-gray-500">
{episodes.length} Episodes
{/* 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 */}
{episodes.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold border-l-4 border-red-600 pl-4">Episodes</h3>
<div className="text-gray-400 text-sm font-medium">{episodes.length} Items</div>
</div>
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2">
{visibleEpisodes.map((ep) => (
<button
key={ep.number}
onClick={() => {
setCurrentEpisode(ep.number);
navigate(`/watch/${slug}/${ep.number}`);
}}
className={`group relative aspect-video rounded-lg overflow-hidden border-2 transition-all ${currentEpisode === ep.number ? 'border-red-600' : 'border-transparent hover:border-white/50 bg-[#222]'}`}
>
<div className="absolute inset-0 flex items-center justify-center">
<span className={`font-bold text-lg ${currentEpisode === ep.number ? 'text-red-500' : 'text-gray-400 group-hover:text-white'}`}>
{ep.number}
</span>
</div>
<div className="grid grid-cols-5 lg:grid-cols-1 gap-2">
{visibleEpisodes.map((ep) => (
<button
key={ep.number}
onClick={() => {
setCurrentEpisode(ep.number);
navigate(`/watch/${slug}/${ep.number}`);
}}
className={`group relative flex items-center justify-center lg:justify-start gap-4 p-2 lg:p-4 rounded-md transition-all duration-300 ${currentEpisode === ep.number
? 'bg-[#333] border-l-2 border-red-600'
: 'bg-[#222] hover:bg-[#333]'
}`}
>
{/* Episode Number/Status */}
<div className="relative">
<div className={`w-8 h-8 lg:w-8 lg:h-8 flex items-center justify-center text-sm font-bold ${currentEpisode === ep.number ? 'text-red-500' : 'text-gray-400 group-hover:text-white'
}`}>
{currentEpisode === ep.number ? <Play className="w-4 h-4 fill-current ml-0.5" /> : ep.number}
</div>
</div>
{/* Episode Info - Show only on Desktop */}
<div className="hidden lg:block flex-1 text-left">
<h4 className={`font-medium text-sm ${currentEpisode === ep.number ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{ep.title || `Episode ${ep.number}`}
</h4>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-gray-500">45m</span>
</div>
</div>
</button>
))}
</div>
{episodes.length > 20 && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-2 py-3 bg-[#222] hover:bg-[#333] rounded-md text-sm font-medium text-gray-300 hover:text-white transition-colors border border-white/10"
>
{expanded ? (
<>Show Less <ChevronUp className="w-4 h-4" /></>
) : (
<>Show All Episodes ({episodes.length}) <ChevronDown className="w-4 h-4" /></>
)}
</button>
)}
</div>
)}
{/* Related Content Section */}
<div className="py-6 border-t border-white/10 w-full space-y-8">
{/* More Like This */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">More Like This</h3>
<MovieRow
title=""
category={movie.category || 'phim-le'}
limit={10}
key={`related-${movie.slug}`}
/>
</div>
{/* Trending */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">New Releases</h3>
<MovieRow
title=""
category="home"
limit={10}
key="trending"
/>
</div>
{/* Top Movies */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">Top Movies</h3>
<MovieRow
title=""
category="phim-le"
limit={10}
key="top-movies"
/>
</div>
{/* Top Series */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">Top Series</h3>
<MovieRow
title=""
category="phim-bo"
limit={10}
key="top-series"
/>
</div>
{/* Animation */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">Animation</h3>
<MovieRow
title=""
category="hoat-hinh"
limit={10}
key="animation"
/>
</div>
{/* TV Shows */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-white">TV Shows</h3>
<MovieRow
title=""
category="tv-shows"
limit={10}
key="tv-shows"
/>
</div>
</div>
</div>
</div>
{/* Main Content Area (Player) */}
<div className="flex-1 relative bg-black flex items-center justify-center z-10">
{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 = movie.episodes?.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>
{currentEpisode === ep.number && (
<div className="absolute bottom-1 right-1">
<Play className="w-3 h-3 text-red-500 fill-current" />
</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>
);
}
)}
</button>
))}
</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`}
/>
);
})()}
{episodes.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>
</Layout>
</div>
);
};

51
start-dev.ps1 Normal file
View file

@ -0,0 +1,51 @@
# 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