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
- **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

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 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():

View file

@ -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,17 +264,7 @@ 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="flex-1 relative w-full h-full overflow-hidden">
<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"
@ -335,8 +277,7 @@ export const Feed: React.FC = () => {
</svg>
</button>
<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' : ''}`}>
<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>
@ -356,9 +297,9 @@ export const Feed: React.FC = () => {
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">
<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 max-w-[500px]">
<div className="w-full h-full">
<VideoPlayer
video={video}
isActive={true}
@ -367,7 +308,7 @@ export const Feed: React.FC = () => {
/>
</div>
) : (
<div className="w-full max-w-[500px] h-full bg-black flex items-center justify-center relative overflow-hidden">
<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>
)}
@ -375,51 +316,5 @@ export const Feed: React.FC = () => {
))}
</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>
)}
</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 { 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 (now - lastTapRef.current < 250) {
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);
}
} 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>