UI: show zoom x number above icon buttons

This commit is contained in:
Khoa Vo 2026-05-12 10:13:33 +07:00
parent 9a4cd8d17d
commit bb56fb557b
3 changed files with 1044 additions and 2120 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,72 +1,41 @@
import React from 'react'; import React from 'react';
import { Home, Search, Heart, LogOut } from 'lucide-react'; import { Home, Heart } from 'lucide-react';
interface SidebarProps { interface SidebarProps {
activeTab: 'foryou' | 'search' | 'profile'; activeTab: 'foryou' | 'likes';
onTabChange: (tab: 'foryou' | 'search' | 'profile') => void; onTabChange: (tab: 'foryou' | 'likes') => void;
onLogout?: () => void;
} }
export const Sidebar: React.FC<SidebarProps> = ({ activeTab, onTabChange, onLogout }) => { export const Sidebar: React.FC<SidebarProps> = ({ activeTab, onTabChange }) => {
return ( return (
<div className="hidden md:flex flex-col w-20 lg:w-64 h-full glass-panel border-r-0 border-r-white/10 z-50 transition-all duration-300"> <div className="hidden md:flex flex-col w-16 h-full glass-panel border-r-0 border-r-white/10 z-50 justify-center items-center py-8">
{/* Logo */} <div className="flex flex-col gap-4 items-center">
<div className="p-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-gradient-to-tr from-violet-500 to-fuchsia-500 flex-shrink-0" />
<h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-gray-400 hidden lg:block">
PureStream
</h1>
</div>
{/* Nav Items */}
<div className="flex-1 flex flex-col gap-2 px-3 py-4">
<NavItem <NavItem
icon={<Home size={24} />} icon={<Home size={24} />}
label="For You"
isActive={activeTab === 'foryou'} isActive={activeTab === 'foryou'}
onClick={() => onTabChange('foryou')} onClick={() => onTabChange('foryou')}
/> />
<NavItem
icon={<Search size={24} />}
label="Search"
isActive={activeTab === 'search'}
onClick={() => onTabChange('search')}
/>
{/* Placeholder for future features */}
<NavItem <NavItem
icon={<Heart size={24} />} icon={<Heart size={24} />}
label="Likes" isActive={activeTab === 'likes'}
isActive={false} onClick={() => onTabChange('likes')}
onClick={() => { }}
/> />
</div> </div>
{/* Bottom Actions */}
<div className="p-4 border-t border-white/10 space-y-2">
<button
onClick={onLogout}
className="flex items-center gap-4 w-full p-3 rounded-xl text-gray-400 hover:bg-white/5 hover:text-red-400 transition-all group"
>
<LogOut size={22} className="group-hover:scale-110 transition-transform" />
<span className="hidden lg:block font-medium">Log Out</span>
</button>
</div>
</div> </div>
); );
}; };
interface NavItemProps { interface NavItemProps {
icon: React.ReactNode; icon: React.ReactNode;
label: string;
isActive: boolean; isActive: boolean;
onClick: () => void; onClick: () => void;
} }
const NavItem: React.FC<NavItemProps> = ({ icon, label, isActive, onClick }) => { const NavItem: React.FC<NavItemProps> = ({ icon, isActive, onClick }) => {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`flex items-center gap-4 w-full p-3 rounded-xl transition-all duration-200 group className={`p-3 rounded-xl transition-all duration-200 group
${isActive ${isActive
? 'bg-white/10 text-white shadow-lg shadow-black/20' ? 'bg-white/10 text-white shadow-lg shadow-black/20'
: 'text-gray-400 hover:bg-white/5 hover:text-white' : 'text-gray-400 hover:bg-white/5 hover:text-white'
@ -75,9 +44,6 @@ const NavItem: React.FC<NavItemProps> = ({ icon, label, isActive, onClick }) =>
<div className={`${isActive ? 'scale-110' : 'group-hover:scale-110'} transition-transform duration-200`}> <div className={`${isActive ? 'scale-110' : 'group-hover:scale-110'} transition-transform duration-200`}>
{icon} {icon}
</div> </div>
<span className={`hidden lg:block font-medium ${isActive ? 'text-white' : ''}`}>
{label}
</span>
</button> </button>
); );
}; };

View file

@ -1,11 +1,9 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { Download, UserPlus, Check, Volume2, VolumeX, AlertCircle } from 'lucide-react'; import { Download, Volume2, VolumeX, AlertCircle, ZoomIn, ZoomOut } from 'lucide-react';
import type { Video } from '../types'; import type { Video } from '../types';
import { API_BASE_URL } from '../config'; import { API_BASE_URL } from '../config';
import { videoCache } from '../utils/videoCache'; import { videoCache } from '../utils/videoCache';
interface HeartParticle { interface HeartParticle {
id: number; id: number;
x: number; x: number;
@ -15,20 +13,14 @@ interface HeartParticle {
interface VideoPlayerProps { interface VideoPlayerProps {
video: Video; video: Video;
isActive: boolean; isActive: boolean;
isFollowing?: boolean; isMuted?: boolean;
onFollow?: (author: string) => void; onMuteToggle?: () => void;
onAuthorClick?: (author: string) => void; // In-app navigation to creator onPauseChange?: (isPaused: boolean) => void;
isMuted?: boolean; // Global mute state from parent
onMuteToggle?: () => void; // Callback to toggle parent mute state
onPauseChange?: (isPaused: boolean) => void; // Notify parent when play/pause state changes
} }
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
video, video,
isActive, isActive,
isFollowing = false,
onFollow,
onAuthorClick,
isMuted: externalMuted, isMuted: externalMuted,
onMuteToggle, onMuteToggle,
onPauseChange onPauseChange
@ -46,25 +38,34 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [localMuted, setLocalMuted] = useState(true); const [localMuted, setLocalMuted] = useState(true);
const isMuted = externalMuted !== undefined ? externalMuted : localMuted; const isMuted = externalMuted !== undefined ? externalMuted : localMuted;
const [hearts, setHearts] = useState<HeartParticle[]>([]); const [hearts, setHearts] = useState<HeartParticle[]>([]);
const [isLoading, setIsLoading] = useState(true); // Show loading spinner until video is ready const [isLoading, setIsLoading] = useState(true);
const [cachedUrl, setCachedUrl] = useState<string | null>(null); const [cachedUrl, setCachedUrl] = useState<string | null>(null);
const [codecError, setCodecError] = useState(false); // True if video codec not supported const [codecError, setCodecError] = useState(false);
const lastTapRef = useRef<number>(0); const lastTapRef = useRef<number>(0);
// Zoom state
const [zoomLevel, setZoomLevel] = useState(1);
const [showZoomIndicator, setShowZoomIndicator] = useState(false);
const initialPinchDistance = useRef<number | null>(null);
const initialZoom = useRef<number>(1);
const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`; const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`;
const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null; const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null;
const proxyUrl = cachedUrl ? cachedUrl : (thinProxyUrl && !useFallback) ? thinProxyUrl : fullProxyUrl; const proxyUrl = cachedUrl ? cachedUrl : (thinProxyUrl && !useFallback) ? thinProxyUrl : fullProxyUrl;
const downloadUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}&download=true`; const downloadUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}&download=true`;
// Reset zoom when video changes
useEffect(() => {
setZoomLevel(1);
}, [video.id]);
// Reset state when video changes
useEffect(() => { useEffect(() => {
if (isActive && videoRef.current) { if (isActive && videoRef.current) {
// Auto-play when becoming active
if (videoRef.current.paused) { if (videoRef.current.paused) {
videoRef.current.currentTime = 0; videoRef.current.currentTime = 0;
videoRef.current.muted = isMuted; // Use current mute state videoRef.current.muted = isMuted;
videoRef.current.play().catch((err) => { videoRef.current.play().catch((err) => {
// If autoplay fails even muted, show paused state
console.log('Autoplay blocked:', err.message); console.log('Autoplay blocked:', err.message);
setIsPaused(true); setIsPaused(true);
}); });
@ -73,21 +74,18 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
} else if (!isActive && videoRef.current) { } else if (!isActive && videoRef.current) {
videoRef.current.pause(); videoRef.current.pause();
} }
}, [isActive]); // Only trigger on isActive change }, [isActive]);
// Sync video muted property when isMuted state changes
useEffect(() => { useEffect(() => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.muted = isMuted; videoRef.current.muted = isMuted;
} }
}, [isMuted]); }, [isMuted]);
// Spacebar to pause/play when this video is active
useEffect(() => { useEffect(() => {
if (!isActive) return; if (!isActive) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Only handle spacebar when not typing
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
@ -109,15 +107,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isActive]); }, [isActive]);
const [showSidebar, setShowSidebar] = useState(false);
// Reset fallback and loading state when video changes
useEffect(() => { useEffect(() => {
setUseFallback(false); setUseFallback(false);
setIsLoading(true); // Show loading for new video setIsLoading(true);
setCodecError(false); // Reset codec error for new video setCodecError(false);
setCachedUrl(null); setCachedUrl(null);
setShowSidebar(false); // Reset sidebar for new video setZoomLevel(1);
const checkCache = async () => { const checkCache = async () => {
const cached = await videoCache.get(video.url); const cached = await videoCache.get(video.url);
@ -130,7 +125,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
checkCache(); checkCache();
}, [video.id]); }, [video.id]);
// Progress tracking
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
@ -143,24 +137,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
setDuration(video.duration); setDuration(video.duration);
}; };
// Fallback on error - if thin proxy fails, switch to full proxy
// Also detect codec errors for graceful fallback UI
const handleError = (e: Event) => { const handleError = (e: Event) => {
const videoEl = e.target as HTMLVideoElement; const videoEl = e.target as HTMLVideoElement;
const error = videoEl?.error; const error = videoEl?.error;
// Check if this is a codec/decode error (MEDIA_ERR_DECODE = 3, MEDIA_ERR_SRC_NOT_SUPPORTED = 4)
if (error?.code === 3 || error?.code === 4) { if (error?.code === 3 || error?.code === 4) {
console.log(`Codec error detected (code ${error.code}):`, error.message); console.log(`Codec error detected (code ${error.code}):`, error.message);
// Always fall back to full proxy which will transcode to H.264
if (!useFallback) { if (!useFallback) {
console.log('Codec not supported, falling back to full proxy (will transcode to H.264)...'); console.log('Codec not supported, falling back to full proxy (will transcode to H.264)...');
setUseFallback(true); setUseFallback(true);
return; return;
} }
// If even full proxy failed, show error
setCodecError(true); setCodecError(true);
setIsLoading(false); setIsLoading(false);
return; return;
@ -217,12 +206,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
} }
}; };
const toggleMute = (e: React.MouseEvent | React.TouchEvent) => { const toggleMute = (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation(); // Prevent video tap e.stopPropagation();
if (!videoRef.current) return; if (!videoRef.current) return;
// Use external toggle if provided, otherwise use local state
if (onMuteToggle) { if (onMuteToggle) {
onMuteToggle(); onMuteToggle();
} else { } else {
@ -230,51 +217,85 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
} }
}; };
// Handle tap - double tap shows heart, single tap toggles play // Zoom functions
const zoomIn = (e: React.MouseEvent) => {
e.stopPropagation();
setZoomLevel(prev => Math.min(prev + 0.25, 3));
setShowZoomIndicator(true);
};
const zoomOut = (e: React.MouseEvent) => {
e.stopPropagation();
setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
setShowZoomIndicator(true);
};
const resetZoom = (e: React.MouseEvent) => {
e.stopPropagation();
setZoomLevel(1);
setShowZoomIndicator(true);
};
// Hide zoom indicator after delay
useEffect(() => {
if (showZoomIndicator) {
const timer = setTimeout(() => setShowZoomIndicator(false), 1500);
return () => clearTimeout(timer);
}
}, [showZoomIndicator]);
// Pinch to zoom handler
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (e.touches.length === 2) {
e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const currentDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
if (initialPinchDistance.current === null) {
initialPinchDistance.current = currentDistance;
initialZoom.current = zoomLevel;
}
const scale = currentDistance / initialPinchDistance.current;
const newZoom = Math.max(0.5, Math.min(3, initialZoom.current * scale));
setZoomLevel(newZoom);
setShowZoomIndicator(true);
}
};
const handleTouchEnd = () => {
initialPinchDistance.current = null;
};
// Heart animation
const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Touch handler for multi-touch support
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => { const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
setShowControls(true); // Ensure controls show on touch setShowControls(true);
const now = Date.now(); const now = Date.now();
const touches = Array.from(e.changedTouches); const touches = Array.from(e.changedTouches);
// Use container rect for stable coordinates vs e.target
if (!containerRef.current) return; if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const isMultiTouch = e.touches.length > 1; // Any simultaneous touches = hearts const isMultiTouch = e.touches.length > 1;
// Check if this is a rapid tap sequence (potential hearts)
let isRapid = false; let isRapid = false;
touches.forEach((touch, index) => { touches.forEach((touch, index) => {
const timeSinceLastTap = now - lastTapRef.current; const timeSinceLastTap = now - lastTapRef.current;
// Swipe Left from right edge check (to open sidebar)
const rectWidth = rect.width;
const startX = touch.clientX - rect.left;
// If touch starts near right edge (last 15% of screen)
if (startX > rectWidth * 0.85 && touches.length === 1) {
// We'll handle the actual swipe logic in touchMove/End,
// but setting a flag or using the existing click logic might be easier.
// For now, let's allow a simple tap on the edge to toggle too, as per existing click logic.
}
// Show heart if:
// 1. Double tap (< 400ms)
// 2. OR Multi-touch (2+ fingers)
// 3. OR Secondary touch in this event
if (timeSinceLastTap < 400 || isMultiTouch || index > 0) { if (timeSinceLastTap < 400 || isMultiTouch || index > 0) {
isRapid = true; isRapid = true;
const x = touch.clientX - rect.left; const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top; const y = touch.clientY - rect.top;
// Add heart const heartId = Date.now() + index + Math.random();
const heartId = Date.now() + index + Math.random(); // Unique ID
setHearts(prev => [...prev, { id: heartId, x, y }]); setHearts(prev => [...prev, { id: heartId, x, y }]);
setTimeout(() => { setTimeout(() => {
@ -284,7 +305,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
}); });
if (isRapid) { if (isRapid) {
// It was a heart tap - prevent default click (toggle pause)
if (tapTimeoutRef.current) { if (tapTimeoutRef.current) {
clearTimeout(tapTimeoutRef.current); clearTimeout(tapTimeoutRef.current);
tapTimeoutRef.current = null; tapTimeoutRef.current = null;
@ -294,30 +314,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
lastTapRef.current = now; lastTapRef.current = now;
}; };
// Click handler (Mouse / Single Touch fallback)
const handleVideoClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleVideoClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If we recently parsed a rapid touch (heart), ignore this click
const now = Date.now(); const now = Date.now();
if (now - lastTapRef.current < 100) return; if (now - lastTapRef.current < 100) return;
// Check for double-click (for hearts)
if (tapTimeoutRef.current) { if (tapTimeoutRef.current) {
// Double click detected - show heart instead of toggle
clearTimeout(tapTimeoutRef.current); clearTimeout(tapTimeoutRef.current);
tapTimeoutRef.current = null; tapTimeoutRef.current = null;
// Add heart at click position
if (containerRef.current) { if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const y = e.clientY - rect.top; const y = e.clientY - rect.top;
// If clicked on the right edge, toggle sidebar
if (x > rect.width * 0.9) {
setShowSidebar(prev => !prev);
return;
}
const heartId = Date.now() + Math.random(); const heartId = Date.now() + Math.random();
setHearts(prev => [...prev, { id: heartId, x, y }]); setHearts(prev => [...prev, { id: heartId, x, y }]);
setTimeout(() => { setTimeout(() => {
@ -325,7 +334,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
}, 1000); }, 1000);
} }
} else { } else {
// First click - set timeout for double-click detection
tapTimeoutRef.current = setTimeout(() => { tapTimeoutRef.current = setTimeout(() => {
togglePlayPause(); togglePlayPause();
tapTimeoutRef.current = null; tapTimeoutRef.current = null;
@ -376,8 +384,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onMouseLeave={() => setShowControls(false)} onMouseLeave={() => setShowControls(false)}
onClick={handleVideoClick} onClick={handleVideoClick}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
> >
{/* Video Element - preload="metadata" for instant player readiness */} {/* Video Element */}
<video <video
ref={videoRef} ref={videoRef}
src={proxyUrl} src={proxyUrl}
@ -386,13 +396,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
preload="metadata" preload="metadata"
muted={isMuted} muted={isMuted}
className="w-full h-full" className="w-full h-full"
style={{ objectFit }} style={{ objectFit, transform: `scale(${zoomLevel})`, transition: zoomLevel !== 1 ? 'none' : 'transform 0.2s ease-out' }}
onCanPlay={() => setIsLoading(false)} onCanPlay={() => setIsLoading(false)}
onWaiting={() => setIsLoading(true)} onWaiting={() => setIsLoading(true)}
onPlaying={() => setIsLoading(false)} onPlaying={() => setIsLoading(false)}
/> />
{/* Loading Overlay - Subtle pulsing logo */} {/* Zoom Indicator - centered (shown during pinch) */}
{/* Loading Overlay */}
{isLoading && !codecError && ( {isLoading && !codecError && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-20"> <div className="absolute inset-0 flex items-center justify-center bg-black/40 z-20">
<div className="w-16 h-16 bg-gradient-to-r from-gray-400/80 to-gray-300/80 rounded-2xl flex items-center justify-center animate-pulse"> <div className="w-16 h-16 bg-gradient-to-r from-gray-400/80 to-gray-300/80 rounded-2xl flex items-center justify-center animate-pulse">
@ -403,7 +415,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
</div> </div>
)} )}
{/* Codec Error Fallback - Graceful UI for unsupported video codecs */} {/* Codec Error Fallback */}
{codecError && ( {codecError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 z-20 p-6 text-center"> <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 z-20 p-6 text-center">
<AlertCircle className="w-12 h-12 text-amber-400 mb-3" /> <AlertCircle className="w-12 h-12 text-amber-400 mb-3" />
@ -467,14 +479,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
className="h-full bg-gradient-to-r from-gray-400 to-gray-300 transition-all pointer-events-none" className="h-full bg-gradient-to-r from-gray-400 to-gray-300 transition-all pointer-events-none"
style={{ width: duration ? `${(progress / duration) * 100}%` : '0%' }} style={{ width: duration ? `${(progress / duration) * 100}%` : '0%' }}
/> />
{/* Scrubber Thumb (always visible when seeking or on hover) */}
<div <div
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full shadow-lg transition-opacity pointer-events-none ${isSeeking ? 'opacity-100 scale-110' : 'opacity-0 group-hover:opacity-100' className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full shadow-lg transition-opacity pointer-events-none ${isSeeking ? 'opacity-100 scale-110' : 'opacity-0 group-hover:opacity-100'
}`} }`}
style={{ left: duration ? `calc(${(progress / duration) * 100}% - 8px)` : '0' }} style={{ left: duration ? `calc(${(progress / duration) * 100}% - 8px)` : '0' }}
/> />
</div> </div>
{/* Time Display */}
{showControls && duration > 0 && ( {showControls && duration > 0 && (
<div className="flex justify-between px-4 py-1 text-xs text-white/60"> <div className="flex justify-between px-4 py-1 text-xs text-white/60">
<span>{formatTime(progress)}</span> <span>{formatTime(progress)}</span>
@ -483,22 +493,44 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)} )}
</div> </div>
{/* Side Controls - Always visible on hover or when paused */} {/* Side Controls */}
<div <div
className={`absolute bottom-36 right-4 flex flex-col gap-3 transition-all duration-300 transform ${showControls || isPaused ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0' className={`absolute bottom-36 right-4 flex flex-col gap-3 transition-all duration-300 transform ${showControls || isPaused ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0'
}`} }`}
> >
{/* Follow Button */} {/* Zoom Indicator */}
{onFollow && ( {showZoomIndicator && zoomLevel !== 1 && (
<div className="absolute bottom-full mb-3 left-1/2 -translate-x-1/2 bg-black/60 backdrop-blur-sm px-4 py-2 rounded-full z-50 pointer-events-none">
<span className="text-white font-medium">{zoomLevel}x</span>
</div>
)}
{/* Zoom In */}
<button
onClick={zoomIn}
className="w-12 h-12 flex items-center justify-center bg-white/10 hover:bg-white/20 backdrop-blur-xl border border-white/10 rounded-full text-white transition-all"
title="Zoom In"
>
<ZoomIn size={20} />
</button>
{/* Zoom Out */}
<button
onClick={zoomOut}
className="w-12 h-12 flex items-center justify-center bg-white/10 hover:bg-white/20 backdrop-blur-xl border border-white/10 rounded-full text-white transition-all"
title="Zoom Out"
>
<ZoomOut size={20} />
</button>
{/* Reset Zoom (only show when zoomed) */}
{zoomLevel !== 1 && (
<button <button
onClick={(e) => { e.stopPropagation(); onFollow(video.author); }} onClick={resetZoom}
className={`w-12 h-12 flex items-center justify-center backdrop-blur-xl border border-white/10 rounded-full transition-all ${isFollowing className="w-12 h-12 flex items-center justify-center bg-white/10 hover:bg-white/20 backdrop-blur-xl border border-white/10 rounded-full text-white transition-all text-xs font-bold"
? 'bg-gray-500 text-white' title="Reset Zoom"
: 'bg-white/10 hover:bg-white/20 text-white'
}`}
title={isFollowing ? 'Following' : 'Follow'}
> >
{isFollowing ? <Check size={20} /> : <UserPlus size={20} />} 1x
</button> </button>
)} )}
@ -523,22 +555,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
</button> </button>
</div> </div>
{/* Author Info - Only show when video is paused */} {/* Author Info */}
<div className={`absolute bottom-10 left-4 right-20 z-10 transition-opacity duration-300 ${isPaused ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}> <div className={`absolute bottom-10 left-4 right-20 z-10 transition-opacity duration-300 ${isPaused ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <span className="text-white font-semibold text-sm truncate">
onClick={(e) => {
e.stopPropagation();
onAuthorClick?.(video.author);
}}
className="text-white font-semibold text-sm truncate hover:text-white/70 transition-colors inline-flex items-center gap-1"
>
@{video.author} @{video.author}
<svg className="w-3 h-3 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> </span>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</button>
{video.views && ( {video.views && (
<span className="text-white/40 text-xs"> <span className="text-white/40 text-xs">
{video.views >= 1000000 {video.views >= 1000000
@ -557,21 +579,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)} )}
</div> </div>
{/* Bottom Gradient - Only show when video is paused */} {/* Bottom Gradient */}
<div className={`absolute bottom-0 left-0 w-full h-32 bg-gradient-to-t from-black/80 to-transparent pointer-events-none transition-opacity duration-300 ${isPaused ? 'opacity-100' : 'opacity-0'}`} /> <div className={`absolute bottom-0 left-0 w-full h-32 bg-gradient-to-t from-black/80 to-transparent pointer-events-none transition-opacity duration-300 ${isPaused ? 'opacity-100' : 'opacity-0'}`} />
{/* Right Sidebar Hint - Only show when video is paused */}
<div
className={`absolute top-0 right-0 w-6 h-full z-40 flex items-center justify-end cursor-pointer transition-opacity duration-300 ${isPaused ? 'opacity-100' : 'opacity-0 pointer-events-none'} ${showSidebar ? 'pointer-events-none' : ''}`}
onClick={(e) => { e.stopPropagation(); setShowSidebar(true); }}
onTouchEnd={() => {
// Check if it was a swipe logic here or just rely on the click/tap
setShowSidebar(true);
}}
>
{/* Visual Hint */}
<div className="w-1 h-12 bg-white/20 rounded-full mr-1 shadow-[0_0_10px_rgba(255,255,255,0.3)] animate-pulse" />
</div>
</div> </div>
); );
}; };