feat: Add episode progress tracking and fix image URLs
Some checks failed
StreamFlow CI/CD / Backend Tests (push) Failing after 23s
StreamFlow CI/CD / Backend Lint (push) Failing after 2s
StreamFlow CI/CD / Frontend Tests (push) Failing after 3s
StreamFlow CI/CD / Android TV Build (push) Failing after 1s
StreamFlow CI/CD / Docker Build (push) Has been skipped
StreamFlow CI/CD / Docker Publish (push) Failing after 1m55s

- Add useWatchProgress hook for saving watch progress
- Auto-save progress every 5 seconds and on pause
- Seek to saved position (minus 20s) when returning
- Add Continue Watching section with progress bars
- Fix ophim image URLs (img.ophim.live)
- Remove broken wsrv.nl proxy dependency
- Add episode badge and progress bar to MovieCard
This commit is contained in:
vndangkhoa 2026-05-06 21:06:05 +07:00
parent 3009f94fe9
commit 0819a1beca
8 changed files with 656 additions and 400 deletions

View file

@ -220,12 +220,12 @@ func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, erro
thumb := item.ThumbURL
if !strings.HasPrefix(thumb, "http") {
// Search API might return relative paths too
thumb = "https://img.ophim1.com/uploads/movies/" + thumb
thumb = "https://img.ophim.live/uploads/movies/" + thumb
}
backdrop := item.PosterURL
if !strings.HasPrefix(backdrop, "http") {
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop
backdrop = "https://img.ophim.live/uploads/movies/" + backdrop
}
movies = append(movies, models.RophimMovie{
@ -273,12 +273,12 @@ func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error)
thumb := movie.ThumbURL
if !strings.HasPrefix(thumb, "http") {
thumb = "https://img.ophim1.com/uploads/movies/" + thumb
thumb = "https://img.ophim.live/uploads/movies/" + thumb
}
backdrop := movie.PosterURL
if !strings.HasPrefix(backdrop, "http") {
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop
backdrop = "https://img.ophim.live/uploads/movies/" + backdrop
}
var episodes []models.Episode

View file

@ -34,8 +34,13 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
// Helper to generate robust image URLs
const getImageUrl = (url: string | undefined, width: number, blur: number = 0) => {
if (!url) return '';
// Unified logic: Simple encoding like Card.tsx, relying on wsrv.nl's robust handling
return `https://wsrv.nl/?url=${encodeURIComponent(url)}&w=${width}&output=webp${blur ? `&blur=${blur}` : ''}&fit=cover`;
let cleanUrl = url;
if (url.startsWith('//')) {
cleanUrl = `https:${url}`;
} else if (!url.startsWith('http')) {
cleanUrl = `https://${url}`;
}
return cleanUrl;
};
// --- Variant-Specific Styles ---

View file

@ -7,6 +7,7 @@ import { CATEGORIES } from '../constants';
import { useMyList } from '../hooks/useMyList';
import { useSmartRecommendations } from '../hooks/useSmartRecommendations';
import { useWatchProgress } from '../hooks/useWatchProgress';
interface HomeContentProps {
topPadding?: string;
@ -20,6 +21,8 @@ export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => {
const [hasMore, setHasMore] = useState(true);
const { watchHistory, savedMovies } = useMyList(); // Access History and MyList
const { getContinueWatchingMovies } = useWatchProgress();
const continueWatching = getContinueWatchingMovies();
const [searchParams] = useSearchParams();
const query = searchParams.get('q');
const category = searchParams.get('category');
@ -124,8 +127,8 @@ export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => {
{showRows && (
<div className="space-y-4 relative z-10 mb-12">
{/* Continue Watching Row */}
{watchHistory.length > 0 && (
<MovieRow title="Tiếp tục xem" movies={watchHistory} />
{continueWatching.length > 0 && (
<MovieRow title="Tiếp tục xem" movies={continueWatching} />
)}
{/* My List Row */}

View file

@ -12,15 +12,20 @@ interface MovieCardProps {
export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => {
const [imgError, setImgError] = useState(false);
// Calculate progress percentage
const progressPercent = movie.watchedTimestamp && movie.duration
? (movie.watchedTimestamp / movie.duration) * 100
: 0;
const getImageUrl = (url: string, width: number) => {
if (!url) return '';
let cleanUrl = url;
if (url.includes('img.ophim1.com')) {
cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com');
} else if (url.startsWith('//')) {
if (url.startsWith('//')) {
cleanUrl = `https:${url}`;
} else if (!url.startsWith('http')) {
cleanUrl = `https://${url}`;
}
return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl.replace(/^https?:\/\//, ''))}&w=${width}&output=webp`;
return cleanUrl;
};
return (
@ -64,6 +69,15 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
</div>
)}
{/* Episode Badge for Continue Watching */}
{movie.currentEpisode && (
<div className="absolute top-2 left-2 mt-7">
<div className="bg-cyan-500/90 backdrop-blur-md px-1.5 py-0.5 rounded text-[9px] font-bold text-black border border-cyan-400/20">
Tập {movie.currentEpisode}
</div>
</div>
)}
{/* Top-Right Tags (Quality & Lang) */}
<div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end">
{movie.quality && (
@ -87,6 +101,16 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
</div>
</div>
)}
{/* Progress Bar for Continue Watching */}
{progressPercent > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-600/50">
<div
className="h-full bg-cyan-500 transition-all duration-300"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
</div>
)}
</Link>
{/* Info Section */}

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import Hls from 'hls.js';
import type { MovieDetail, VideoSource } from '../types';
import { useWatchProgress } from './useWatchProgress';
export const useWatchMovie = (slug: string | undefined, episode: string | undefined) => {
const videoRef = useRef<HTMLVideoElement>(null);
@ -8,6 +9,40 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
const [source, setSource] = useState<VideoSource | null>(null);
const [loading, setLoading] = useState(true);
const [currentEpisode, setCurrentEpisode] = useState(parseInt(episode || '1'));
const { getProgress, saveProgress, clearProgress } = useWatchProgress();
const saveIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Refs to avoid effect re-running when these functions change
const getProgressRef = useRef(getProgress);
const saveProgressRef = useRef(saveProgress);
const clearProgressRef = useRef(clearProgress);
const movieRef = useRef(movie);
// Update refs when values change
useEffect(() => {
getProgressRef.current = getProgress;
}, [getProgress]);
useEffect(() => {
saveProgressRef.current = saveProgress;
}, [saveProgress]);
useEffect(() => {
clearProgressRef.current = clearProgress;
}, [clearProgress]);
useEffect(() => {
movieRef.current = movie;
}, [movie]);
// Load saved progress on mount
useEffect(() => {
if (!slug) return;
const progress = getProgress(slug);
if (progress) {
setCurrentEpisode(progress.episode);
}
}, [slug, getProgress]);
useEffect(() => {
if (!slug) return;
@ -24,6 +59,16 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
fetchDetails();
}, [slug]);
// Save progress when episode changes
useEffect(() => {
if (!slug) return;
const progress = getProgress(slug);
if (progress && progress.episode !== currentEpisode) {
// Clear old progress when switching episodes
clearProgress(slug);
}
}, [currentEpisode, slug, getProgress, clearProgress]);
useEffect(() => {
if (!movie) return;
@ -56,7 +101,7 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
const res = await fetch(`/api/extract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: targetUrl }) // Changed to JSON payload
body: JSON.stringify({ url: targetUrl })
});
if (!res.ok) throw new Error('Failed to extract');
@ -77,29 +122,81 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
fetchStream();
}, [movie, currentEpisode, slug]);
// Save progress periodically and seek to saved position
useEffect(() => {
if (source && videoRef.current) {
console.log("Initializing player with source:", source);
if (!source || !videoRef.current || !slug) return;
const video = videoRef.current;
let hls: Hls | null = null;
const saveCurrentProgress = () => {
if (video && slug && movieRef.current) {
const currentTime = video.currentTime;
const duration = video.duration;
if (duration > 0) {
saveProgressRef.current(slug, currentEpisode, currentTime, duration, {
title: movieRef.current.title,
thumbnail: movieRef.current.thumbnail,
backdrop: movieRef.current.backdrop,
year: movieRef.current.year,
category: movieRef.current.category,
});
}
}
};
const onLoadedMetadata = () => {
// Seek to saved position (minus 20s) if available
const progress = getProgressRef.current(slug);
if (progress && progress.episode === currentEpisode && progress.timestamp > 0) {
// Rewind 20 seconds so user doesn't miss the exact moment
video.currentTime = Math.max(0, progress.timestamp - 20);
}
};
const onPause = () => {
saveCurrentProgress();
};
const onEnded = () => {
// Clear progress when video ends
clearProgressRef.current(slug);
};
const isHls = source.stream_url.includes('.m3u8') || source.format_id === 'hls';
console.log("Is HLS:", isHls, "Stream URL:", source.stream_url);
if (isHls && Hls.isSupported()) {
const hls = new Hls();
hls = new Hls();
hls.loadSource(source.stream_url);
hls.attachMedia(videoRef.current);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
videoRef.current?.play().catch(() => { });
video.play().catch(() => { });
});
return () => {
hls.destroy();
};
} else {
// MP4 or Native HLS (Safari)
videoRef.current.src = source.stream_url;
videoRef.current.play().catch(() => { });
video.src = source.stream_url;
video.play().catch(() => { });
}
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('pause', onPause);
video.addEventListener('ended', onEnded);
// Save progress every 5 seconds
saveIntervalRef.current = setInterval(saveCurrentProgress, 5000);
return () => {
if (hls) hls.destroy();
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('pause', onPause);
video.removeEventListener('ended', onEnded);
if (saveIntervalRef.current) {
clearInterval(saveIntervalRef.current);
saveIntervalRef.current = null;
}
}, [source]);
// Save final progress on unmount
saveCurrentProgress();
};
}, [source, slug, currentEpisode]);
// Wake Lock Logic (Prevent Screen Sleep)
useEffect(() => {

View file

@ -0,0 +1,118 @@
import { useState, useEffect, useCallback } from 'react';
import type { Movie } from '../types';
const STORAGE_KEY = 'streamflow_watch_progress';
interface ProgressData {
episode: number;
timestamp: number;
duration: number;
updatedAt: string;
}
interface StoredProgress {
[slug: string]: ProgressData;
}
export interface WatchProgress extends ProgressData {
slug: string;
movieTitle?: string;
movieThumbnail?: string;
movieBackdrop?: string;
movieYear?: number;
movieCategory?: string;
}
export const useWatchProgress = () => {
const [progressMap, setProgressMap] = useState<StoredProgress>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
});
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(progressMap));
} catch (e) {
console.error('Failed to save watch progress:', e);
}
}, [progressMap]);
const getProgress = useCallback((slug: string): ProgressData | null => {
return progressMap[slug] || null;
}, [progressMap]);
const saveProgress = useCallback((slug: string, episode: number, timestamp: number, duration: number, movieInfo?: {
title?: string;
thumbnail?: string;
backdrop?: string;
year?: number;
category?: string;
}) => {
setProgressMap(prev => ({
...prev,
[slug]: {
episode,
timestamp,
duration,
updatedAt: new Date().toISOString(),
movieTitle: movieInfo?.title,
movieThumbnail: movieInfo?.thumbnail,
movieBackdrop: movieInfo?.backdrop,
movieYear: movieInfo?.year,
movieCategory: movieInfo?.category,
}
}));
}, []);
const getAllProgress = useCallback((): WatchProgress[] => {
return Object.entries(progressMap)
.map(([slug, data]) => ({
slug,
...data,
}))
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}, [progressMap]);
const getContinueWatchingMovies = useCallback((): Movie[] => {
return Object.entries(progressMap)
.map(([slug, data]) => ({
id: slug,
title: data.movieTitle || slug,
slug: slug,
thumbnail: data.movieThumbnail || '',
backdrop: data.movieBackdrop || undefined,
year: data.movieYear || undefined,
category: data.movieCategory || 'movies',
// Add progress info for display
currentEpisode: data.episode,
watchedTimestamp: data.timestamp,
duration: data.duration,
} as Movie))
.sort((a, b) => new Date(progressMap[b.slug!].updatedAt).getTime() - new Date(progressMap[a.slug!].updatedAt).getTime());
}, [progressMap]);
const clearProgress = useCallback((slug: string) => {
setProgressMap(prev => {
const newMap = { ...prev };
delete newMap[slug];
return newMap;
});
}, []);
const clearAllProgress = useCallback(() => {
setProgressMap({});
}, []);
return {
getProgress,
saveProgress,
getAllProgress,
getContinueWatchingMovies,
clearProgress,
clearAllProgress,
};
};

View file

@ -22,8 +22,13 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
// 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`;
let cleanUrl = url;
if (url.startsWith('//')) {
cleanUrl = `https:${url}`;
} else if (!url.startsWith('http')) {
cleanUrl = `https://${url}`;
}
return cleanUrl;
};
const episodesByServer = movie?.episodes?.reduce((acc, ep) => {
const server = ep.server_name || 'Default';

View file

@ -13,6 +13,10 @@ export interface Movie {
provider?: string;
director?: string;
cast?: string[];
// Progress tracking
currentEpisode?: number;
watchedTimestamp?: number;
duration?: number;
}
export interface MovieDetail extends Movie {