Remove likes feature, update README, refresh Docker image

This commit is contained in:
Khoa Vo 2026-05-12 14:11:18 +07:00
parent 7b099e2f9d
commit 448a05d287
7 changed files with 251 additions and 249 deletions

View file

@ -7,17 +7,18 @@
- **For You Feed** — Video feed from popular TikTok creators - **For You Feed** — Video feed from popular TikTok creators
- **Search** — Search videos and users - **Search** — Search videos and users
- **Following** — Track your favorite creators - **Following** — Track your favorite creators
- **Liked Videos** — Save favorites to a persistent Liked tab
- **Download** — Download videos directly - **Download** — Download videos directly
- **Autoplay** — Muted autoplay with tap-to-unmute - **Autoplay** — Muted autoplay with tap-to-unmute
- **Mobile-friendly** — Responsive design for any screen - **Mobile-friendly** — Responsive design for any screen
- **Docker-ready** — Single container, easy deployment on Synology NAS - **Docker-ready** — Single container, easy deployment on Synology NAS
- **Object-fit contain** — Videos preserve original aspect ratio with letterboxing
## Architecture ## Architecture
- **Backend**: Python FastAPI with Playwright for TikTok interaction - **Backend**: Python FastAPI with Playwright for TikTok interaction
- **Frontend**: React + Vite - **Frontend**: React + Vite (SPA)
- **Platform**: `linux/amd64` (compatible with Synology NAS x86/x64 models) - **Platform**: `linux/amd64` (compatible with Synology NAS x86/x64 models)
- **Video source**: CDN-first with fallback proxy, forces full download for playback
## Prerequisites ## Prerequisites

View file

@ -0,0 +1,56 @@
"""
Likes API routes - manage locally saved liked videos.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List
from core.local_likes import add_like, remove_like, is_liked, get_likes, clear_likes
router = APIRouter(prefix="/likes", tags=["likes"])
class VideoLike(BaseModel):
id: str
url: str
author: str
description: Optional[str] = None
thumbnail: Optional[str] = None
cdn_url: Optional[str] = None
views: Optional[int] = None
@router.post("")
async def like_video(video: VideoLike):
"""Add a video to liked list."""
result = add_like(video.model_dump())
return result
@router.delete("/{video_id}")
async def unlike_video(video_id: str):
"""Remove a video from liked list."""
result = remove_like(video_id)
return result
@router.get("")
async def get_liked_videos():
"""Get all liked videos."""
likes = get_likes()
return {"videos": likes, "count": len(likes)}
@router.get("/check/{video_id}")
async def check_liked(video_id: str):
"""Check if a video is liked."""
liked = is_liked(video_id)
return {"video_id": video_id, "liked": liked}
@router.delete("")
async def clear_all_likes():
"""Clear all liked videos."""
result = clear_likes()
return result

View file

@ -0,0 +1,88 @@
"""
Local likes storage - saves liked videos locally when user double-taps.
"""
import os
import json
import asyncio
import hashlib
from typing import List, Optional
from datetime import datetime
router = likes_router = None
# Lazy import to avoid circular dependencies
def get_router():
global likes_router
if likes_router is None:
from fastapi import APIRouter
likes_router = APIRouter(prefix="/likes", tags=["likes"])
return likes_router
LIKES_FILE = "/tmp/purestream_likes.json"
_likes_cache = None
def _load_likes() -> List[dict]:
global _likes_cache
if _likes_cache is not None:
return _likes_cache
if os.path.exists(LIKES_FILE):
try:
with open(LIKES_FILE, "r") as f:
_likes_cache = json.load(f)
except:
_likes_cache = []
else:
_likes_cache = []
return _likes_cache
def _save_likes(likes: List[dict]):
global _likes_cache
_likes_cache = likes
with open(LIKES_FILE, "w") as f:
json.dump(likes, f, indent=2)
def add_like(video: dict) -> dict:
"""Add a video to local likes."""
likes = _load_likes()
# Check if already liked
if any(v.get("id") == video.get("id") for v in likes):
return {"status": "already_liked", "video": video}
# Add timestamp
video["liked_at"] = datetime.now().isoformat()
likes.insert(0, video) # Add to beginning
_save_likes(likes)
return {"status": "added", "video": video}
def remove_like(video_id: str) -> dict:
"""Remove a video from local likes."""
likes = _load_likes()
likes = [v for v in likes if v.get("id") != video_id]
_save_likes(likes)
return {"status": "removed", "video_id": video_id}
def is_liked(video_id: str) -> bool:
"""Check if video is liked."""
likes = _load_likes()
return any(v.get("id") == video_id for v in likes)
def get_likes() -> List[dict]:
"""Get all liked videos."""
return _load_likes()
def clear_likes():
"""Clear all likes."""
_save_likes([])
return {"status": "cleared"}

View file

@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from api.routes import auth, feed, download, following, config, user from api.routes import auth, feed, download, following, config, user, likes
@asynccontextmanager @asynccontextmanager
@ -58,6 +58,7 @@ app.include_router(download.router, prefix="/api/download", tags=["Download"])
app.include_router(following.router, prefix="/api/following", tags=["Following"]) app.include_router(following.router, prefix="/api/following", tags=["Following"])
app.include_router(config.router, prefix="/api/config", tags=["Config"]) app.include_router(config.router, prefix="/api/config", tags=["Config"])
app.include_router(user.router, prefix="/api/user", tags=["User"]) app.include_router(user.router, prefix="/api/user", tags=["User"])
app.include_router(likes.router, prefix="/api", tags=["Likes"])
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():

View file

@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { VideoPlayer } from './VideoPlayer'; import { VideoPlayer } from './VideoPlayer';
import { SkeletonFeed } from './SkeletonFeed'; import { SkeletonFeed } from './SkeletonFeed';
import { Sidebar } from './Sidebar';
import type { Video } from '../types'; import type { Video } from '../types';
import axios from 'axios'; import axios from 'axios';
import { API_BASE_URL } from '../config'; import { API_BASE_URL } from '../config';
@ -9,13 +8,10 @@ import { videoPrefetcher } from '../utils/videoPrefetch';
import { feedLoader } from '../utils/feedLoader'; import { feedLoader } from '../utils/feedLoader';
type ViewState = 'login' | 'loading' | 'feed'; type ViewState = 'login' | 'loading' | 'feed';
type TabType = 'foryou' | 'likes';
export const Feed: React.FC = () => { export const Feed: React.FC = () => {
const [viewState, setViewState] = useState<ViewState>('login'); const [viewState, setViewState] = useState<ViewState>('login');
const [activeTab, setActiveTab] = useState<TabType>('foryou');
const [videos, setVideos] = useState<Video[]>([]); const [videos, setVideos] = useState<Video[]>([]);
const [likesVideos, setLikesVideos] = useState<Video[]>([]);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [jsonInput, setJsonInput] = useState(''); const [jsonInput, setJsonInput] = useState('');
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -25,8 +21,6 @@ export const Feed: React.FC = () => {
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const likesContainerRef = useRef<HTMLDivElement>(null);
const [likesCurrentIndex, setLikesCurrentIndex] = useState(0);
useEffect(() => { useEffect(() => {
checkAuthStatus(); checkAuthStatus();
@ -35,29 +29,16 @@ export const Feed: React.FC = () => {
useEffect(() => { useEffect(() => {
const prefetch = async () => { const prefetch = async () => {
await videoPrefetcher.init(); await videoPrefetcher.init();
const currentVideos = activeTab === 'foryou' ? videos : likesVideos; videoPrefetcher.prefetchNext(videos, currentIndex);
const currentIdx = activeTab === 'foryou' ? currentIndex : likesCurrentIndex;
videoPrefetcher.prefetchNext(currentVideos, currentIdx);
}; };
prefetch(); prefetch();
}, [currentIndex, likesCurrentIndex, videos, likesVideos, activeTab]); }, [currentIndex, videos]);
useEffect(() => {
const ref = activeTab === 'foryou' ? containerRef : likesContainerRef;
if (ref.current) {
const targetScroll = (activeTab === 'foryou' ? currentIndex : likesCurrentIndex) * ref.current.clientHeight;
if (Math.abs(ref.current.scrollTop - targetScroll) > 50) {
ref.current.scrollTo({ top: targetScroll, behavior: 'auto' });
}
}
}, [videos, likesVideos, activeTab]);
const checkAuthStatus = async () => { const checkAuthStatus = async () => {
try { try {
const res = await axios.get(`${API_BASE_URL}/auth/status`); const res = await axios.get(`${API_BASE_URL}/auth/status`);
if (res.data.authenticated) { if (res.data.authenticated) {
loadFeed(); loadFeed();
loadLikesFeed();
} }
} catch (err) { } catch (err) {
console.log('Not authenticated'); console.log('Not authenticated');
@ -71,7 +52,6 @@ export const Feed: React.FC = () => {
const res = await axios.post(`${API_BASE_URL}/auth/browser-login`); const res = await axios.post(`${API_BASE_URL}/auth/browser-login`);
if (res.data.status === 'success') { if (res.data.status === 'success') {
loadFeed(); loadFeed();
loadLikesFeed();
} else { } else {
setError(res.data.message || 'Login failed'); setError(res.data.message || 'Login failed');
setViewState('login'); setViewState('login');
@ -93,7 +73,6 @@ export const Feed: React.FC = () => {
const credentials = JSON.parse(jsonInput); const credentials = JSON.parse(jsonInput);
await axios.post(`${API_BASE_URL}/auth/credentials`, { credentials }); await axios.post(`${API_BASE_URL}/auth/credentials`, { credentials });
loadFeed(); loadFeed();
loadLikesFeed();
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Invalid JSON format'); setError(err.message || 'Invalid JSON format');
setViewState('login'); setViewState('login');
@ -131,22 +110,6 @@ export const Feed: React.FC = () => {
} }
}; };
const loadLikesFeed = async () => {
try {
const res = await axios.get(`${API_BASE_URL}/following`);
const followingList = res.data || [];
if (followingList.length > 0) {
const username = followingList[0];
const videosRes = await axios.get(`${API_BASE_URL}/user/liked?username=${username}&limit=30`);
if (videosRes.data.videos) {
setLikesVideos(videosRes.data.videos);
}
}
} catch (err) {
console.error('Failed to load likes:', err);
}
};
const handleScroll = () => { const handleScroll = () => {
if (containerRef.current) { if (containerRef.current) {
const { scrollTop, clientHeight } = containerRef.current; const { scrollTop, clientHeight } = containerRef.current;
@ -161,16 +124,6 @@ export const Feed: React.FC = () => {
} }
}; };
const handleLikesScroll = () => {
if (likesContainerRef.current) {
const { scrollTop, clientHeight } = likesContainerRef.current;
const index = Math.round(scrollTop / clientHeight);
if (index !== likesCurrentIndex) {
setLikesCurrentIndex(index);
}
}
};
const loadMoreVideos = async () => { const loadMoreVideos = async () => {
if (isFetching || !hasMore) return; if (isFetching || !hasMore) return;
setIsFetching(true); setIsFetching(true);
@ -192,7 +145,6 @@ export const Feed: React.FC = () => {
const handleLogout = async () => { const handleLogout = async () => {
await axios.post(`${API_BASE_URL}/auth/logout`); await axios.post(`${API_BASE_URL}/auth/logout`);
setVideos([]); setVideos([]);
setLikesVideos([]);
setViewState('login'); setViewState('login');
}; };
@ -312,113 +264,56 @@ export const Feed: React.FC = () => {
} }
return ( return (
<div className="flex w-full h-screen bg-[#0f0f15] text-white overflow-hidden"> <div className="w-full h-screen bg-black overflow-hidden">
<Sidebar <button
activeTab={activeTab} onClick={handleLogout}
onTabChange={(tab) => { className="absolute top-6 right-6 z-50 w-10 h-10 flex items-center justify-center bg-black/20 hover:bg-black/40 backdrop-blur-md rounded-full text-white/70 hover:text-white transition-all duration-300"
setActiveTab(tab); title="Logout"
if (tab === 'foryou' && videos.length === 0) loadFeed(); >
if (tab === 'likes' && likesVideos.length === 0) loadLikesFeed(); <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
}} <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
/> <polyline points="16,17 21,12 16,7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
<div className="flex-1 relative w-full h-full overflow-hidden"> <div className="absolute bottom-6 right-4 z-40 px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-full border border-white/10 transition-all">
<button <span className="text-xs text-white/60 font-medium">
onClick={handleLogout} {isFetching ? (
className="absolute top-6 right-6 z-50 w-10 h-10 flex items-center justify-center bg-black/20 hover:bg-black/40 backdrop-blur-md rounded-full text-white/70 hover:text-white transition-all duration-300" <span className="text-white/70">Loading {currentIndex + 1}/{videos.length}...</span>
title="Logout" ) : (
> <>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> {currentIndex + 1} / {videos.length}
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" /> {hasMore && <span className="text-white/70 ml-1">+</span>}
<polyline points="16,17 21,12 16,7" /> </>
<line x1="21" y1="12" x2="9" y2="12" /> )}
</svg> </span>
</button> </div>
<div className={`absolute inset-0 w-full h-full transition-all duration-300 ease-out ${activeTab === 'foryou' ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0 pointer-events-none'}`}> <div
<div className={`absolute bottom-6 right-4 z-40 px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-full border border-white/10 transition-all ${isFetching ? 'animate-pulse border-gray-400/50' : ''}`}> ref={containerRef}
<span className="text-xs text-white/60 font-medium"> onScroll={handleScroll}
{isFetching ? ( className="w-full h-full overflow-y-auto snap-y snap-mandatory scrollbar-hide"
<span className="text-white/70">Loading {currentIndex + 1}/{videos.length}...</span> style={{ scrollbarWidth: 'none' }}
) : ( >
<> {videos.map((video, index) => (
{currentIndex + 1} / {videos.length} <div key={video.id} className="w-full h-full snap-start snap-always bg-black flex justify-center items-center">
{hasMore && <span className="text-white/70 ml-1">+</span>} {index === currentIndex ? (
</> <div className="w-full h-full">
)} <VideoPlayer
</span> video={video}
</div> isActive={true}
isMuted={isMuted}
<div onMuteToggle={() => setIsMuted(prev => !prev)}
ref={containerRef} />
onScroll={handleScroll}
className="w-full h-full overflow-y-auto snap-y snap-mandatory scrollbar-hide"
style={{ scrollbarWidth: 'none' }}
>
{videos.map((video, index) => (
<div key={video.id} className="w-full h-screen-safe snap-start snap-always bg-black flex justify-center">
{index === currentIndex ? (
<div className="w-full max-w-[500px]">
<VideoPlayer
video={video}
isActive={true}
isMuted={isMuted}
onMuteToggle={() => setIsMuted(prev => !prev)}
/>
</div>
) : (
<div className="w-full max-w-[500px] h-full bg-black flex items-center justify-center relative overflow-hidden">
<div className="w-10 h-10 border-4 border-white/10 border-t-white/30 rounded-full animate-spin" />
</div>
)}
</div> </div>
))}
</div>
</div>
<div className={`absolute inset-0 w-full h-full transition-all duration-300 ease-out ${activeTab === 'likes' ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0 pointer-events-none'}`}>
<div className="absolute bottom-6 right-4 z-40 px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-full border border-white/10">
<span className="text-xs text-white/60 font-medium">
{likesCurrentIndex + 1} / {likesVideos.length}
</span>
</div>
<div
ref={likesContainerRef}
onScroll={handleLikesScroll}
className="w-full h-full overflow-y-auto snap-y snap-mandatory scrollbar-hide"
style={{ scrollbarWidth: 'none' }}
>
{likesVideos.length > 0 ? (
likesVideos.map((video, index) => (
<div key={video.id} className="w-full h-screen-safe snap-start snap-always bg-black flex justify-center">
{index === likesCurrentIndex ? (
<div className="w-full max-w-[500px]">
<VideoPlayer
video={video}
isActive={true}
isMuted={isMuted}
onMuteToggle={() => setIsMuted(prev => !prev)}
/>
</div>
) : (
<div className="w-full max-w-[500px] h-full bg-black flex items-center justify-center relative overflow-hidden">
<div className="w-10 h-10 border-4 border-white/10 border-t-white/30 rounded-full animate-spin" />
</div>
)}
</div>
))
) : ( ) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-black"> <div className="w-full h-full bg-black flex items-center justify-center relative overflow-hidden">
<svg className="w-16 h-16 text-white/20 mb-4" viewBox="0 0 24 24" fill="currentColor"> <div className="w-10 h-10 border-4 border-white/10 border-t-white/30 rounded-full animate-spin" />
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
<p className="text-white/40 text-sm">No liked videos yet</p>
<p className="text-white/20 text-xs mt-2">Double-tap videos to like them</p>
</div> </div>
)} )}
</div> </div>
</div> ))}
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,44 @@
import React from 'react';
import type { Video } from '../types';
interface LikedVideosGridProps {
videos: Video[];
onVideoSelect: (video: Video, index: number) => void;
}
export const LikedVideosGrid: React.FC<LikedVideosGridProps> = ({ videos, onVideoSelect }) => {
return (
<div className="w-full h-full overflow-y-auto p-4 bg-black">
<div className="grid grid-cols-3 gap-1">
{videos.map((video, index) => (
<div
key={video.id}
onClick={() => onVideoSelect(video, index)}
className="relative aspect-[9/16] bg-gray-900 cursor-pointer overflow-hidden group"
>
{video.thumbnail ? (
<img
src={video.thumbnail}
alt={`Video by ${video.author}`}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-800">
<svg className="w-8 h-8 text-white/30" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all" />
<div className="absolute bottom-1 left-1 flex items-center text-white text-xs">
<svg className="w-3 h-3 mr-0.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</div>
</div>
))}
</div>
</div>
);
};

View file

@ -4,12 +4,6 @@ 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 {
id: number;
x: number;
y: number;
}
interface VideoPlayerProps { interface VideoPlayerProps {
video: Video; video: Video;
isActive: boolean; isActive: boolean;
@ -31,7 +25,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [isSeeking, setIsSeeking] = useState(false); const [isSeeking, setIsSeeking] = useState(false);
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 [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [cachedUrl, setCachedUrl] = useState<string | null>(null); const [cachedUrl, setCachedUrl] = useState<string | null>(null);
const [codecError, setCodecError] = useState(false); const [codecError, setCodecError] = useState(false);
@ -41,7 +34,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const lastTapRef = useRef<number>(0); const lastTapRef = useRef<number>(0);
// Zoom state
const [zoomLevel, setZoomLevel] = useState(1); const [zoomLevel, setZoomLevel] = useState(1);
const [showZoomIndicator, setShowZoomIndicator] = useState(false); const [showZoomIndicator, setShowZoomIndicator] = useState(false);
const initialPinchDistance = useRef<number | null>(null); const initialPinchDistance = useRef<number | null>(null);
@ -152,7 +144,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
} }
}; };
// Zoom functions
const zoomIn = (e: React.MouseEvent) => { const zoomIn = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setZoomLevel(prev => Math.min(prev + 0.25, 3)); setZoomLevel(prev => Math.min(prev + 0.25, 3));
@ -171,7 +162,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
setShowZoomIndicator(true); setShowZoomIndicator(true);
}; };
// Hide zoom indicator after delay
useEffect(() => { useEffect(() => {
if (showZoomIndicator) { if (showZoomIndicator) {
const timer = setTimeout(() => setShowZoomIndicator(false), 1500); const timer = setTimeout(() => setShowZoomIndicator(false), 1500);
@ -179,7 +169,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
} }
}, [showZoomIndicator]); }, [showZoomIndicator]);
// Pinch to zoom handler
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => { const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (e.touches.length === 2) { if (e.touches.length === 2) {
e.preventDefault(); e.preventDefault();
@ -206,67 +195,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
initialPinchDistance.current = null; initialPinchDistance.current = null;
}; };
// Heart animation
const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => { const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
setShowControls(true); setShowControls(true);
lastTapRef.current = Date.now();
const now = Date.now();
const touches = Array.from(e.changedTouches);
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const isMultiTouch = e.touches.length > 1;
let isRapid = false;
touches.forEach((touch, index) => {
const timeSinceLastTap = now - lastTapRef.current;
if (timeSinceLastTap < 400 || isMultiTouch || index > 0) {
isRapid = true;
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
const heartId = Date.now() + index + Math.random();
setHearts(prev => [...prev, { id: heartId, x, y }]);
setTimeout(() => {
setHearts(prev => prev.filter(h => h.id !== heartId));
}, 1000);
}
});
if (isRapid) {
if (tapTimeoutRef.current) {
clearTimeout(tapTimeoutRef.current);
tapTimeoutRef.current = null;
}
}
lastTapRef.current = now;
}; };
const handleVideoClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleVideoClick = (e: React.MouseEvent<HTMLDivElement>) => {
const now = Date.now(); const now = Date.now();
if (now - lastTapRef.current < 100) return; if (now - lastTapRef.current < 250) {
if (tapTimeoutRef.current) {
if (tapTimeoutRef.current) { clearTimeout(tapTimeoutRef.current);
clearTimeout(tapTimeoutRef.current); tapTimeoutRef.current = null;
tapTimeoutRef.current = null;
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const heartId = Date.now() + Math.random();
setHearts(prev => [...prev, { id: heartId, x, y }]);
setTimeout(() => {
setHearts(prev => prev.filter(h => h.id !== heartId));
}, 1000);
} }
} else { } else {
tapTimeoutRef.current = setTimeout(() => { tapTimeoutRef.current = setTimeout(() => {
@ -274,7 +215,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
tapTimeoutRef.current = null; tapTimeoutRef.current = null;
}, 250); }, 250);
} }
lastTapRef.current = now; lastTapRef.current = now;
}; };
@ -314,7 +254,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="relative w-full max-w-[500px] mx-auto h-full bg-black overflow-hidden" className="relative w-full h-full bg-black overflow-hidden flex items-center justify-center"
onMouseEnter={() => setShowControls(true)} onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)} onMouseLeave={() => setShowControls(false)}
onClick={handleVideoClick} onClick={handleVideoClick}
@ -322,7 +262,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
{/* Video Element */}
<video <video
ref={videoRef} ref={videoRef}
src={videoSrc} src={videoSrc}
@ -332,20 +271,23 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
preload="auto" preload="auto"
muted={isMuted} muted={isMuted}
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
style={{ objectFit: 'cover', transform: `scale(${zoomLevel})`, transition: zoomLevel !== 1 ? 'none' : 'transform 0.2s ease-out' }} style={{ objectFit: 'contain', 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)}
onLoadedMetadata={() => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
}
}}
/> />
{/* Loading Spinner */}
{isLoading && !codecError && ( {isLoading && !codecError && (
<div className="absolute top-4 right-4 z-20"> <div className="absolute top-4 right-4 z-20">
<div className="w-8 h-8 border-2 border-white/30 border-t-white/80 rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-white/30 border-t-white/80 rounded-full animate-spin" />
</div> </div>
)} )}
{/* Codec Error Fallback */}
{codecError && ( {codecError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-20 p-6 text-center"> <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 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" />
@ -355,23 +297,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
</div> </div>
)} )}
{/* Heart Animation */}
{hearts.map(heart => (
<div
key={heart.id}
className="absolute z-50 pointer-events-none animate-heart-float"
style={{
left: heart.x - 24,
top: heart.y - 24,
}}
>
<svg className="w-16 h-16 text-white drop-shadow-xl filter drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</div>
))}
{/* Video Timeline/Progress Bar */}
<div className="absolute bottom-0 left-0 right-0 z-30"> <div className="absolute bottom-0 left-0 right-0 z-30">
<div <div
ref={progressBarRef} ref={progressBarRef}
@ -403,10 +328,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)} )}
</div> </div>
{/* Side Controls - centered at bottom of video, above author info */}
<div className={`absolute bottom-20 left-0 right-0 flex justify-center z-40 transition-all duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}> <div className={`absolute bottom-20 left-0 right-0 flex justify-center z-40 transition-all duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{/* Zoom Indicator - above buttons */}
<div <div
className={`transition-all duration-200 ${showZoomIndicator ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2 pointer-events-none'}`} className={`transition-all duration-200 ${showZoomIndicator ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2 pointer-events-none'}`}
> >
@ -416,7 +339,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
</div> </div>
<div className="flex items-center gap-2 bg-black/40 backdrop-blur-xl rounded-full px-4 py-2 border border-white/10"> <div className="flex items-center gap-2 bg-black/40 backdrop-blur-xl rounded-full px-4 py-2 border border-white/10">
{/* Zoom In */}
<button <button
onClick={zoomIn} onClick={zoomIn}
className="w-10 h-10 flex items-center justify-center text-white hover:bg-white/10 rounded-full transition-all" className="w-10 h-10 flex items-center justify-center text-white hover:bg-white/10 rounded-full transition-all"
@ -425,7 +347,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<ZoomIn size={20} /> <ZoomIn size={20} />
</button> </button>
{/* Zoom Out */}
<button <button
onClick={zoomOut} onClick={zoomOut}
className="w-10 h-10 flex items-center justify-center text-white hover:bg-white/10 rounded-full transition-all" className="w-10 h-10 flex items-center justify-center text-white hover:bg-white/10 rounded-full transition-all"
@ -434,7 +355,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<ZoomOut size={20} /> <ZoomOut size={20} />
</button> </button>
{/* Reset Zoom (only show when zoomed) */}
{zoomLevel !== 1 && ( {zoomLevel !== 1 && (
<button <button
onClick={resetZoom} onClick={resetZoom}
@ -447,7 +367,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<div className="w-px h-6 bg-white/20 mx-1" /> <div className="w-px h-6 bg-white/20 mx-1" />
{/* Download Button */}
<a <a
href={downloadUrl} href={downloadUrl}
download download
@ -458,9 +377,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<Download size={20} /> <Download size={20} />
</a> </a>
{/* Mute Toggle */}
<button <button
onClick={toggleMute} onClick={(e) => { e.stopPropagation(); toggleMute(e); }}
className="w-10 h-10 flex items-center justify-center text-white hover:bg-white/10 rounded-full transition-all" className="w-10 h-10 flex items-center justify-center text-white hover:bg-white/10 rounded-full transition-all"
title={isMuted ? 'Unmute' : 'Mute'} title={isMuted ? 'Unmute' : 'Mute'}
> >
@ -470,7 +388,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
</div> </div>
</div> </div>
{/* Author Info - below controls */}
<div className={`absolute bottom-6 left-4 right-4 z-10 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}> <div className={`absolute bottom-6 left-4 right-4 z-10 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-white font-semibold text-sm truncate">@{video.author}</span> <span className="text-white font-semibold text-sm truncate">@{video.author}</span>