diff --git a/README.md b/README.md index 4469ac4..b55ef38 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/api/routes/likes.py b/backend/api/routes/likes.py new file mode 100644 index 0000000..8d47acc --- /dev/null +++ b/backend/api/routes/likes.py @@ -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 \ No newline at end of file diff --git a/backend/core/local_likes.py b/backend/core/local_likes.py new file mode 100644 index 0000000..49bb4c5 --- /dev/null +++ b/backend/core/local_likes.py @@ -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"} \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index b4e1232..3744d5c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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(): diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 7b354d5..67813b1 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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('login'); - const [activeTab, setActiveTab] = useState('foryou'); const [videos, setVideos] = useState([]); - const [likesVideos, setLikesVideos] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [jsonInput, setJsonInput] = useState(''); const containerRef = useRef(null); @@ -25,8 +21,6 @@ export const Feed: React.FC = () => { const [isFetching, setIsFetching] = useState(false); const [hasMore, setHasMore] = useState(true); - const likesContainerRef = useRef(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 ( -
- { - setActiveTab(tab); - if (tab === 'foryou' && videos.length === 0) loadFeed(); - if (tab === 'likes' && likesVideos.length === 0) loadLikesFeed(); - }} - /> +
+ -
- +
+ + {isFetching ? ( + Loading {currentIndex + 1}/{videos.length}... + ) : ( + <> + {currentIndex + 1} / {videos.length} + {hasMore && +} + + )} + +
-
-
- - {isFetching ? ( - Loading {currentIndex + 1}/{videos.length}... - ) : ( - <> - {currentIndex + 1} / {videos.length} - {hasMore && +} - - )} - -
- -
- {videos.map((video, index) => ( -
- {index === currentIndex ? ( -
- setIsMuted(prev => !prev)} - /> -
- ) : ( -
-
-
- )} +
+ {videos.map((video, index) => ( +
+ {index === currentIndex ? ( +
+ setIsMuted(prev => !prev)} + />
- ))} -
-
- -
-
- - {likesCurrentIndex + 1} / {likesVideos.length} - -
- -
- {likesVideos.length > 0 ? ( - likesVideos.map((video, index) => ( -
- {index === likesCurrentIndex ? ( -
- setIsMuted(prev => !prev)} - /> -
- ) : ( -
-
-
- )} -
- )) ) : ( -
- - - -

No liked videos yet

-

Double-tap videos to like them

+
+
)}
-
+ ))}
); diff --git a/frontend/src/components/LikedVideosGrid.tsx b/frontend/src/components/LikedVideosGrid.tsx new file mode 100644 index 0000000..c25cb6c --- /dev/null +++ b/frontend/src/components/LikedVideosGrid.tsx @@ -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 = ({ videos, onVideoSelect }) => { + return ( +
+
+ {videos.map((video, index) => ( +
onVideoSelect(video, index)} + className="relative aspect-[9/16] bg-gray-900 cursor-pointer overflow-hidden group" + > + {video.thumbnail ? ( + {`Video + ) : ( +
+ + + +
+ )} +
+
+ + + +
+
+ ))} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index 8248189..8c53e9d 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -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 = ({ const [isSeeking, setIsSeeking] = useState(false); const [localMuted, setLocalMuted] = useState(true); const isMuted = externalMuted !== undefined ? externalMuted : localMuted; - const [hearts, setHearts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [cachedUrl, setCachedUrl] = useState(null); const [codecError, setCodecError] = useState(false); @@ -41,7 +34,6 @@ export const VideoPlayer: React.FC = ({ const [isPaused, setIsPaused] = useState(false); const lastTapRef = useRef(0); - // Zoom state const [zoomLevel, setZoomLevel] = useState(1); const [showZoomIndicator, setShowZoomIndicator] = useState(false); const initialPinchDistance = useRef(null); @@ -152,7 +144,6 @@ export const VideoPlayer: React.FC = ({ } }; - // 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 = ({ 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 = ({ } }, [showZoomIndicator]); - // Pinch to zoom handler const handleTouchMove = (e: React.TouchEvent) => { if (e.touches.length === 2) { e.preventDefault(); @@ -206,67 +195,19 @@ export const VideoPlayer: React.FC = ({ initialPinchDistance.current = null; }; - // Heart animation const tapTimeoutRef = useRef | null>(null); const handleTouchStart = (e: React.TouchEvent) => { 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) => { 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 = ({ tapTimeoutRef.current = null; }, 250); } - lastTapRef.current = now; }; @@ -314,7 +254,7 @@ export const VideoPlayer: React.FC = ({ return (
setShowControls(true)} onMouseLeave={() => setShowControls(false)} onClick={handleVideoClick} @@ -322,7 +262,6 @@ export const VideoPlayer: React.FC = ({ onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} > - {/* Video Element */}