Remove likes feature, update README, refresh Docker image
This commit is contained in:
parent
7b099e2f9d
commit
448a05d287
7 changed files with 251 additions and 249 deletions
|
|
@ -7,17 +7,18 @@
|
|||
- **For You Feed** — Video feed from popular TikTok creators
|
||||
- **Search** — Search videos and users
|
||||
- **Following** — Track your favorite creators
|
||||
- **Liked Videos** — Save favorites to a persistent Liked tab
|
||||
- **Download** — Download videos directly
|
||||
- **Autoplay** — Muted autoplay with tap-to-unmute
|
||||
- **Mobile-friendly** — Responsive design for any screen
|
||||
- **Docker-ready** — Single container, easy deployment on Synology NAS
|
||||
- **Object-fit contain** — Videos preserve original aspect ratio with letterboxing
|
||||
|
||||
## Architecture
|
||||
|
||||
- **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)
|
||||
- **Video source**: CDN-first with fallback proxy, forces full download for playback
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
|
|||
56
backend/api/routes/likes.py
Normal file
56
backend/api/routes/likes.py
Normal 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
|
||||
88
backend/core/local_likes.py
Normal file
88
backend/core/local_likes.py
Normal 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"}
|
||||
|
|
@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
|
|||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
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
|
||||
|
|
@ -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(config.router, prefix="/api/config", tags=["Config"])
|
||||
app.include_router(user.router, prefix="/api/user", tags=["User"])
|
||||
app.include_router(likes.router, prefix="/api", tags=["Likes"])
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { SkeletonFeed } from './SkeletonFeed';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import type { Video } from '../types';
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../config';
|
||||
|
|
@ -9,13 +8,10 @@ import { videoPrefetcher } from '../utils/videoPrefetch';
|
|||
import { feedLoader } from '../utils/feedLoader';
|
||||
|
||||
type ViewState = 'login' | 'loading' | 'feed';
|
||||
type TabType = 'foryou' | 'likes';
|
||||
|
||||
export const Feed: React.FC = () => {
|
||||
const [viewState, setViewState] = useState<ViewState>('login');
|
||||
const [activeTab, setActiveTab] = useState<TabType>('foryou');
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [likesVideos, setLikesVideos] = useState<Video[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -25,8 +21,6 @@ export const Feed: React.FC = () => {
|
|||
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const likesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [likesCurrentIndex, setLikesCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
|
|
@ -35,29 +29,16 @@ export const Feed: React.FC = () => {
|
|||
useEffect(() => {
|
||||
const prefetch = async () => {
|
||||
await videoPrefetcher.init();
|
||||
const currentVideos = activeTab === 'foryou' ? videos : likesVideos;
|
||||
const currentIdx = activeTab === 'foryou' ? currentIndex : likesCurrentIndex;
|
||||
videoPrefetcher.prefetchNext(currentVideos, currentIdx);
|
||||
videoPrefetcher.prefetchNext(videos, currentIndex);
|
||||
};
|
||||
prefetch();
|
||||
}, [currentIndex, likesCurrentIndex, videos, likesVideos, activeTab]);
|
||||
|
||||
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]);
|
||||
}, [currentIndex, videos]);
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE_URL}/auth/status`);
|
||||
if (res.data.authenticated) {
|
||||
loadFeed();
|
||||
loadLikesFeed();
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Not authenticated');
|
||||
|
|
@ -71,7 +52,6 @@ export const Feed: React.FC = () => {
|
|||
const res = await axios.post(`${API_BASE_URL}/auth/browser-login`);
|
||||
if (res.data.status === 'success') {
|
||||
loadFeed();
|
||||
loadLikesFeed();
|
||||
} else {
|
||||
setError(res.data.message || 'Login failed');
|
||||
setViewState('login');
|
||||
|
|
@ -93,7 +73,6 @@ export const Feed: React.FC = () => {
|
|||
const credentials = JSON.parse(jsonInput);
|
||||
await axios.post(`${API_BASE_URL}/auth/credentials`, { credentials });
|
||||
loadFeed();
|
||||
loadLikesFeed();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Invalid JSON format');
|
||||
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 = () => {
|
||||
if (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 () => {
|
||||
if (isFetching || !hasMore) return;
|
||||
setIsFetching(true);
|
||||
|
|
@ -192,7 +145,6 @@ export const Feed: React.FC = () => {
|
|||
const handleLogout = async () => {
|
||||
await axios.post(`${API_BASE_URL}/auth/logout`);
|
||||
setVideos([]);
|
||||
setLikesVideos([]);
|
||||
setViewState('login');
|
||||
};
|
||||
|
||||
|
|
@ -312,113 +264,56 @@ export const Feed: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-screen bg-[#0f0f15] text-white overflow-hidden">
|
||||
<Sidebar
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
if (tab === 'foryou' && videos.length === 0) loadFeed();
|
||||
if (tab === 'likes' && likesVideos.length === 0) loadLikesFeed();
|
||||
}}
|
||||
/>
|
||||
<div className="w-full h-screen bg-black overflow-hidden">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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"
|
||||
title="Logout"
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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"
|
||||
title="Logout"
|
||||
>
|
||||
<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="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">
|
||||
<span className="text-xs text-white/60 font-medium">
|
||||
{isFetching ? (
|
||||
<span className="text-white/70">Loading {currentIndex + 1}/{videos.length}...</span>
|
||||
) : (
|
||||
<>
|
||||
{currentIndex + 1} / {videos.length}
|
||||
{hasMore && <span className="text-white/70 ml-1">+</span>}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</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 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' : ''}`}>
|
||||
<span className="text-xs text-white/60 font-medium">
|
||||
{isFetching ? (
|
||||
<span className="text-white/70">Loading {currentIndex + 1}/{videos.length}...</span>
|
||||
) : (
|
||||
<>
|
||||
{currentIndex + 1} / {videos.length}
|
||||
{hasMore && <span className="text-white/70 ml-1">+</span>}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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
|
||||
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-full snap-start snap-always bg-black flex justify-center items-center">
|
||||
{index === currentIndex ? (
|
||||
<div className="w-full h-full">
|
||||
<VideoPlayer
|
||||
video={video}
|
||||
isActive={true}
|
||||
isMuted={isMuted}
|
||||
onMuteToggle={() => setIsMuted(prev => !prev)}
|
||||
/>
|
||||
</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">
|
||||
<svg className="w-16 h-16 text-white/20 mb-4" 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>
|
||||
<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 className="w-full 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>
|
||||
);
|
||||
|
|
|
|||
44
frontend/src/components/LikedVideosGrid.tsx
Normal file
44
frontend/src/components/LikedVideosGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,12 +4,6 @@ import type { Video } from '../types';
|
|||
import { API_BASE_URL } from '../config';
|
||||
import { videoCache } from '../utils/videoCache';
|
||||
|
||||
interface HeartParticle {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface VideoPlayerProps {
|
||||
video: Video;
|
||||
isActive: boolean;
|
||||
|
|
@ -31,7 +25,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const [localMuted, setLocalMuted] = useState(true);
|
||||
const isMuted = externalMuted !== undefined ? externalMuted : localMuted;
|
||||
const [hearts, setHearts] = useState<HeartParticle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [cachedUrl, setCachedUrl] = useState<string | null>(null);
|
||||
const [codecError, setCodecError] = useState(false);
|
||||
|
|
@ -41,7 +34,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
const [isPaused, setIsPaused] = useState(false);
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
// Zoom state
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [showZoomIndicator, setShowZoomIndicator] = useState(false);
|
||||
const initialPinchDistance = useRef<number | null>(null);
|
||||
|
|
@ -152,7 +144,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Zoom functions
|
||||
const zoomIn = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setZoomLevel(prev => Math.min(prev + 0.25, 3));
|
||||
|
|
@ -171,7 +162,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
setShowZoomIndicator(true);
|
||||
};
|
||||
|
||||
// Hide zoom indicator after delay
|
||||
useEffect(() => {
|
||||
if (showZoomIndicator) {
|
||||
const timer = setTimeout(() => setShowZoomIndicator(false), 1500);
|
||||
|
|
@ -179,7 +169,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
}
|
||||
}, [showZoomIndicator]);
|
||||
|
||||
// Pinch to zoom handler
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
|
|
@ -206,67 +195,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
initialPinchDistance.current = null;
|
||||
};
|
||||
|
||||
// Heart animation
|
||||
const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||
setShowControls(true);
|
||||
|
||||
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;
|
||||
lastTapRef.current = Date.now();
|
||||
};
|
||||
|
||||
const handleVideoClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const now = Date.now();
|
||||
if (now - lastTapRef.current < 100) return;
|
||||
|
||||
if (tapTimeoutRef.current) {
|
||||
clearTimeout(tapTimeoutRef.current);
|
||||
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);
|
||||
if (now - lastTapRef.current < 250) {
|
||||
if (tapTimeoutRef.current) {
|
||||
clearTimeout(tapTimeoutRef.current);
|
||||
tapTimeoutRef.current = null;
|
||||
}
|
||||
} else {
|
||||
tapTimeoutRef.current = setTimeout(() => {
|
||||
|
|
@ -274,7 +215,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
tapTimeoutRef.current = null;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
lastTapRef.current = now;
|
||||
};
|
||||
|
||||
|
|
@ -314,7 +254,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
return (
|
||||
<div
|
||||
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)}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
onClick={handleVideoClick}
|
||||
|
|
@ -322,7 +262,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Video Element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
|
|
@ -332,20 +271,23 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
preload="auto"
|
||||
muted={isMuted}
|
||||
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)}
|
||||
onWaiting={() => setIsLoading(true)}
|
||||
onPlaying={() => setIsLoading(false)}
|
||||
onLoadedMetadata={() => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Loading Spinner */}
|
||||
{isLoading && !codecError && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Codec Error Fallback */}
|
||||
{codecError && (
|
||||
<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" />
|
||||
|
|
@ -355,23 +297,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
</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
|
||||
ref={progressBarRef}
|
||||
|
|
@ -403,10 +328,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
)}
|
||||
</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="flex flex-col items-center gap-2">
|
||||
{/* Zoom Indicator - above buttons */}
|
||||
<div
|
||||
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 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
|
||||
onClick={zoomIn}
|
||||
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} />
|
||||
</button>
|
||||
|
||||
{/* Zoom Out */}
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
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} />
|
||||
</button>
|
||||
|
||||
{/* Reset Zoom (only show when zoomed) */}
|
||||
{zoomLevel !== 1 && (
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
|
|
@ -447,7 +367,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
|
||||
<div className="w-px h-6 bg-white/20 mx-1" />
|
||||
|
||||
{/* Download Button */}
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
|
|
@ -458,9 +377,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
<Download size={20} />
|
||||
</a>
|
||||
|
||||
{/* Mute Toggle */}
|
||||
<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"
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
|
|
@ -470,7 +388,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
</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="flex items-center gap-2">
|
||||
<span className="text-white font-semibold text-sm truncate">@{video.author}</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue