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
|
- **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
|
||||||
|
|
||||||
|
|
|
||||||
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 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():
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue