feat: enhance story viewer and media playback experience

This commit is contained in:
ergosteur
2026-03-07 00:31:04 -05:00
parent 47e44ec5e9
commit f685eaebd7

View File

@@ -16,7 +16,9 @@ import {
MessageCircle, MessageCircle,
Bookmark, Bookmark,
MoreHorizontal, MoreHorizontal,
Loader2 Loader2,
Volume2,
VolumeX
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
@@ -52,38 +54,56 @@ interface Post {
const StoryViewer = ({ const StoryViewer = ({
stories, stories,
onClose onClose,
profilePic
}: { }: {
stories: Post[]; stories: Post[];
onClose: () => void; onClose: () => void;
profilePic: string | null;
}) => { }) => {
const [currentStoryIndex, setCurrentStoryIndex] = useState(0); const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [isMuted, setIsMuted] = useState(false);
const videoRef = React.useRef<HTMLVideoElement>(null);
const story = stories[currentStoryIndex]; const story = stories[currentStoryIndex];
useEffect(() => { useEffect(() => {
setProgress(0); setProgress(0);
const duration = 5000; // 5 seconds per story let duration = 5000; // Default 5s for images
const interval = 50; const interval = 50;
const step = (interval / duration) * 100;
const updateProgress = () => {
if (story.media[0].type === 'video' && videoRef.current) {
const currentTime = videoRef.current.currentTime;
const totalTime = videoRef.current.duration;
if (totalTime) {
setProgress((currentTime / totalTime) * 100);
}
} else {
setProgress(prev => {
const step = (interval / duration) * 100;
if (prev >= 100) return 100;
return prev + step;
});
}
};
const timer = setInterval(() => { const timer = setInterval(() => {
setProgress(prev => { updateProgress();
if (prev >= 100) {
if (currentStoryIndex < stories.length - 1) {
setCurrentStoryIndex(prev => prev + 1);
return 0;
} else {
onClose();
return 100;
}
}
return prev + step;
});
}, interval); }, interval);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [currentStoryIndex, stories.length, onClose]); }, [currentStoryIndex, story.media]);
useEffect(() => {
if (progress >= 100) {
if (currentStoryIndex < stories.length - 1) {
setCurrentStoryIndex(prev => prev + 1);
} else {
onClose();
}
}
}, [progress, currentStoryIndex, stories.length, onClose]);
const nextStory = () => { const nextStory = () => {
if (currentStoryIndex < stories.length - 1) { if (currentStoryIndex < stories.length - 1) {
@@ -101,22 +121,53 @@ const StoryViewer = ({
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-black flex items-center justify-center" className="fixed inset-0 z-[100] bg-[#1a1a1a] flex items-center justify-center overflow-hidden"
onClick={onClose} onClick={onClose}
> >
{/* Background Blur */}
<div className="absolute inset-0 z-0">
<img
src={story.media[0].url}
alt=""
className="w-full h-full object-cover blur-3xl opacity-30"
/>
</div>
{/* Navigation Arrows (Desktop) */}
<button
onClick={(e) => { e.stopPropagation(); prevStory(); }}
className={cn(
"hidden md:flex absolute left-4 lg:left-20 z-50 text-white/80 hover:text-white transition-all bg-white/10 p-3 rounded-full backdrop-blur-md",
currentStoryIndex === 0 && "opacity-0 pointer-events-none"
)}
>
<ChevronLeft size={32} strokeWidth={1.5} />
</button>
<button
onClick={(e) => { e.stopPropagation(); nextStory(); }}
className="hidden md:flex absolute right-4 lg:right-20 z-50 text-white/80 hover:text-white transition-all bg-white/10 p-3 rounded-full backdrop-blur-md"
>
<ChevronRight size={32} strokeWidth={1.5} />
</button>
{/* Main Container */}
<div <div
className="relative w-full max-w-md aspect-[9/16] bg-gray-900 overflow-hidden md:rounded-xl shadow-2xl" className="relative w-full h-full md:h-[90vh] md:max-w-[45vh] bg-black overflow-hidden md:rounded-lg shadow-2xl z-10"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{/* Progress Bars */} {/* Progress Bars */}
<div className="absolute top-4 left-4 right-4 z-50 flex gap-1"> <div
className="absolute top-2 left-2 right-2 z-50 flex px-1"
style={{ gap: stories.length > 100 ? '1px' : (stories.length > 50 ? '2px' : '4px') }}
>
{stories.map((_, i) => ( {stories.map((_, i) => (
<div key={i} className="h-0.5 flex-1 bg-white/30 rounded-full overflow-hidden"> <div key={i} className="h-1 flex-1 bg-white/20 rounded-full overflow-hidden">
<div <div
className="h-full bg-white transition-all duration-50" className="h-full bg-white transition-all duration-75"
style={{ style={{
width: i < currentStoryIndex ? '100%' : (i === currentStoryIndex ? `${progress}%` : '0%') width: i < currentStoryIndex ? '100%' : (i === currentStoryIndex ? `${progress}%` : '0%')
}} }}
@@ -126,29 +177,47 @@ const StoryViewer = ({
</div> </div>
{/* Header */} {/* Header */}
<div className="absolute top-8 left-4 right-4 z-50 flex items-center justify-between text-white"> <div className="absolute top-6 left-4 right-4 z-50 flex items-center justify-between text-white">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-[10px] font-bold text-black uppercase"> <div className="w-8 h-8 rounded-full bg-white/10 p-0.5">
{story.username[0]} <div className="w-full h-full rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
{profilePic ? (
<img src={profilePic} alt="" className="w-full h-full object-cover" referrerPolicy="no-referrer" />
) : (
<span className="text-[10px] font-bold text-black uppercase">{story.username[0]}</span>
)}
</div>
</div> </div>
<div className="flex flex-col"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold">{story.username}</span> <span className="text-xs font-semibold">{story.username}</span>
<span className="text-[10px] opacity-70">{format(parseISO(story.date), 'MMM d')}</span> <span className="text-[10px] opacity-60 font-medium">{format(parseISO(story.date), 'MMM d')}</span>
</div> </div>
</div> </div>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors">
<X size={24} /> <div className="flex items-center gap-1">
</button> {story.media[0].type === 'video' && (
<button
onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
)}
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors">
<X size={24} />
</button>
</div>
</div> </div>
{/* Media */} {/* Media */}
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center pointer-events-none">
{story.media[0].type === 'video' ? ( {story.media[0].type === 'video' ? (
<video <video
ref={videoRef}
src={story.media[0].url} src={story.media[0].url}
className="w-full h-full object-cover" className="w-full h-full object-contain"
autoPlay autoPlay
muted muted={isMuted}
playsInline playsInline
onEnded={nextStory} onEnded={nextStory}
/> />
@@ -156,21 +225,21 @@ const StoryViewer = ({
<img <img
src={story.media[0].url} src={story.media[0].url}
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-contain"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
)} )}
</div> </div>
{/* Navigation Areas */} {/* Interaction Areas */}
<div className="absolute inset-0 flex"> <div className="absolute inset-0 z-20 flex">
<div className="w-1/3 h-full cursor-pointer" onClick={prevStory} /> <div className="w-1/4 h-full cursor-pointer" onClick={prevStory} title="Previous Story" />
<div className="w-2/3 h-full cursor-pointer" onClick={nextStory} /> <div className="w-3/4 h-full cursor-pointer" onClick={nextStory} title="Next Story" />
</div> </div>
{/* Caption */} {/* Caption Overlay */}
{story.caption && ( {story.caption && (
<div className="absolute bottom-10 left-4 right-4 z-50 text-white text-sm text-center drop-shadow-md"> <div className="absolute bottom-16 left-4 right-4 z-50 bg-black/20 backdrop-blur-sm p-3 rounded-lg text-white text-xs text-center border border-white/10">
{story.caption} {story.caption}
</div> </div>
)} )}
@@ -290,6 +359,7 @@ const VideoThumbnail = ({ url, className }: { url: string; className?: string })
}; };
const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => { const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
const [isMuted, setIsMuted] = useState(false);
const sizingClass = isFullView const sizingClass = isFullView
? "w-full h-auto block" ? "w-full h-auto block"
: "w-full h-full object-cover"; : "w-full h-full object-cover";
@@ -298,18 +368,27 @@ const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; class
if (file.type === 'video') { if (file.type === 'video') {
return ( return (
<video <div className="relative w-full h-full flex items-center justify-center">
src={file.url} <video
className={cn( src={file.url}
"transition-all duration-300", className={cn(
sizingClass, "transition-all duration-300",
className sizingClass,
)} className
style={mediaStyle} )}
controls style={mediaStyle}
playsInline playsInline
autoPlay autoPlay
/> muted={isMuted}
loop
/>
<button
onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }}
className="absolute bottom-4 right-4 z-30 bg-black/40 hover:bg-black/60 text-white p-2 rounded-full backdrop-blur-md transition-all"
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
); );
} }
return ( return (
@@ -330,7 +409,6 @@ const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; class
const PostModal = ({ const PostModal = ({
post, post,
onClose, onClose,
initialFullView = false,
onNextPost, onNextPost,
onPrevPost, onPrevPost,
hasNextPost, hasNextPost,
@@ -339,7 +417,6 @@ const PostModal = ({
}: { }: {
post: Post; post: Post;
onClose: () => void; onClose: () => void;
initialFullView?: boolean;
onNextPost?: () => void; onNextPost?: () => void;
onPrevPost?: () => void; onPrevPost?: () => void;
hasNextPost?: boolean; hasNextPost?: boolean;
@@ -347,7 +424,6 @@ const PostModal = ({
profilePic: string | null; profilePic: string | null;
}) => { }) => {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isFullView, setIsFullView] = useState(initialFullView);
const [direction, setDirection] = useState(0); const [direction, setDirection] = useState(0);
// Reset currentIndex when post changes // Reset currentIndex when post changes
@@ -464,14 +540,8 @@ const PostModal = ({
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{/* Media Section */} {/* Media Section */}
<div className={cn( <div className="relative bg-black flex items-center justify-center group overflow-hidden w-full h-auto">
"relative bg-black flex items-center justify-center group overflow-hidden", <div className="w-full grid grid-cols-1 grid-rows-1">
isFullView ? "w-full h-auto" : "w-full aspect-square"
)}>
<div className={cn(
"w-full grid grid-cols-1 grid-rows-1",
isFullView ? "" : "absolute inset-0 h-full"
)}>
<AnimatePresence initial={false} custom={direction}> <AnimatePresence initial={false} custom={direction}>
<motion.div <motion.div
key={`${post.id}-${currentIndex}`} key={`${post.id}-${currentIndex}`}
@@ -504,29 +574,13 @@ const PostModal = ({
} }
} }
}} }}
className={cn( className="col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing relative"
"col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing",
isFullView ? "relative" : "h-full"
)}
> >
<MediaRenderer file={post.media[currentIndex]} isFullView={isFullView} /> <MediaRenderer file={post.media[currentIndex]} isFullView={true} />
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* Full/Square Toggle Overlay */}
<button
onClick={(e) => {
e.stopPropagation();
setIsFullView(!isFullView);
}}
className="absolute top-4 left-4 md:top-4 md:right-4 bg-black/40 hover:bg-black/60 text-white p-2 rounded-full backdrop-blur-md transition-all flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider z-30 md:opacity-0 md:group-hover:opacity-100"
title={isFullView ? "Crop to Square" : "View Original Aspect Ratio"}
>
{isFullView ? <Grid3X3 size={16} /> : <Layers size={16} />}
<span className="hidden sm:inline">{isFullView ? "Square" : "Full"}</span>
</button>
{post.media.length > 1 && ( {post.media.length > 1 && (
<> <>
{currentIndex > 0 && ( {currentIndex > 0 && (
@@ -1052,7 +1106,7 @@ export default function App() {
}); });
const posts = allItems.filter(p => !p.isStory).sort((a, b) => b.date.localeCompare(a.date)); const posts = allItems.filter(p => !p.isStory).sort((a, b) => b.date.localeCompare(a.date));
const stories = allItems.filter(p => p.isStory).sort((a, b) => b.date.localeCompare(a.date)); const stories = allItems.filter(p => p.isStory).sort((a, b) => a.date.localeCompare(b.date));
console.log(`Finalized ${posts.length} posts and ${stories.length} stories.`); console.log(`Finalized ${posts.length} posts and ${stories.length} stories.`);
setAllPosts(posts); setAllPosts(posts);
@@ -1376,7 +1430,6 @@ export default function App() {
<PostModal <PostModal
post={selectedPost} post={selectedPost}
onClose={() => setSelectedPost(null)} onClose={() => setSelectedPost(null)}
initialFullView={activeTab === 'reels' || gridAspectRatio === '3:4'}
onNextPost={onNextPost} onNextPost={onNextPost}
onPrevPost={onPrevPost} onPrevPost={onPrevPost}
hasNextPost={postIndex < filteredPosts.length - 1} hasNextPost={postIndex < filteredPosts.length - 1}
@@ -1392,6 +1445,7 @@ export default function App() {
<StoryViewer <StoryViewer
stories={allStories} stories={allStories}
onClose={() => setShowStoryViewer(false)} onClose={() => setShowStoryViewer(false)}
profilePic={profilePic}
/> />
)} )}
</AnimatePresence> </AnimatePresence>