UI: show zoom x number above icon buttons
This commit is contained in:
parent
9a4cd8d17d
commit
bb56fb557b
3 changed files with 1044 additions and 2120 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Loading…
Reference in a new issue