diff --git a/backend/api/routes/auth.py b/backend/api/routes/auth.py index fc00a87..d0e8225 100644 --- a/backend/api/routes/auth.py +++ b/backend/api/routes/auth.py @@ -18,8 +18,10 @@ class BrowserLoginResponse(BaseModel): cookie_count: int = 0 +from typing import Any + class CredentialsRequest(BaseModel): - credentials: dict # JSON credentials in http.headers format + credentials: Any # Accept both dict and list class CredentialLoginRequest(BaseModel): @@ -79,9 +81,8 @@ async def save_credentials(request: CredentialsRequest): if not cookies: raise HTTPException(status_code=400, detail="No cookies found in credentials") - # Convert to dict format for storage - cookie_dict = {c["name"]: c["value"] for c in cookies} - PlaywrightManager.save_credentials(cookie_dict, user_agent) + # Save full cookie list with domains/paths preserved + PlaywrightManager.save_credentials(cookies, user_agent) return { "status": "success", @@ -187,22 +188,30 @@ async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""): try: cookies = request.cookies - # Normalize cookies to dict format + # Preserve list if it contains metadata (like domain) if isinstance(cookies, list): - # Cookie-Editor export format: [{"name": "...", "value": "..."}, ...] - cookie_dict = {} - for c in cookies: - if isinstance(c, dict) and "name" in c and "value" in c: - cookie_dict[c["name"]] = c["value"] - cookies = cookie_dict + # Check if this is a simple name-value list or full objects + if len(cookies) > 0 and isinstance(cookies[0], dict) and "domain" not in cookies[0]: + cookie_dict = {} + for c in cookies: + if isinstance(c, dict) and "name" in c and "value" in c: + cookie_dict[c["name"]] = c["value"] + cookies = cookie_dict - if not isinstance(cookies, dict): + if not isinstance(cookies, (dict, list)): raise HTTPException(status_code=400, detail="Invalid cookies format") - if "sessionid" not in cookies: + # Check for sessionid in both formats + has_session = False + if isinstance(cookies, dict): + has_session = "sessionid" in cookies + else: + has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict)) + + if not has_session: raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required") - # Save cookies + # Save cookies (either dict or list) PlaywrightManager.save_credentials(cookies, None) return { diff --git a/backend/api/routes/feed.py b/backend/api/routes/feed.py index 8d7b7bc..4bcc0c6 100644 --- a/backend/api/routes/feed.py +++ b/backend/api/routes/feed.py @@ -132,9 +132,11 @@ init_cache() # ========== API ROUTES ========== +from typing import Optional, Any, Union, List, Dict + class FeedRequest(BaseModel): """Request body for feed endpoint with optional JSON credentials.""" - credentials: Optional[dict] = None + credentials: Optional[Union[Dict, List]] = None @router.post("") diff --git a/backend/api/routes/following.py b/backend/api/routes/following.py index 4ca1cd1..643b7a1 100644 --- a/backend/api/routes/following.py +++ b/backend/api/routes/following.py @@ -63,3 +63,36 @@ async def remove_following(username: str): save_following(following) return {"status": "success", "following": following} + + +@router.get("/feed") +async def get_following_feed(limit_per_user: int = 5): + """ + Get a combined feed of videos from all followed creators. + """ + from core.playwright_manager import PlaywrightManager + import asyncio + + following = load_following() + if not following: + return [] + + # Load stored credentials + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + if not cookies: + raise HTTPException(status_code=401, detail="Not authenticated") + + tasks = [PlaywrightManager.fetch_user_videos(user, cookies, user_agent, limit_per_user) for user in following] + results = await asyncio.gather(*tasks, return_exceptions=True) + + all_videos = [] + for result in results: + if isinstance(result, list): + all_videos.extend(result) + + # Shuffle results to make it look like a feed + import random + random.shuffle(all_videos) + + return all_videos diff --git a/backend/api/routes/user.py b/backend/api/routes/user.py index b448ead..f6bb7eb 100644 --- a/backend/api/routes/user.py +++ b/backend/api/routes/user.py @@ -108,7 +108,7 @@ async def get_multiple_profiles(usernames: str = Query(..., description="Comma-s @router.get("/videos") async def get_user_videos( username: str = Query(..., description="TikTok username (without @)"), - limit: int = Query(10, description="Max videos to fetch", ge=1, le=30) + limit: int = Query(10, description="Max videos to fetch", ge=1, le=60) ): """ Fetch videos from a TikTok user's profile. @@ -135,7 +135,8 @@ async def get_user_videos( @router.get("/search") async def search_videos( query: str = Query(..., description="Search keyword or hashtag"), - limit: int = Query(12, description="Max videos to fetch", ge=1, le=30) + limit: int = Query(20, description="Max videos to fetch", ge=1, le=60), + cursor: int = Query(0, description="Pagination cursor (offset)") ): """ Search for videos by keyword or hashtag. @@ -147,11 +148,11 @@ async def search_videos( if not cookies: raise HTTPException(status_code=401, detail="Not authenticated") - print(f"Searching for: {query}...") + print(f"Searching for: {query} (limit={limit}, cursor={cursor})...") try: - videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit) - return {"query": query, "videos": videos, "count": len(videos)} + videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit, cursor) + return {"query": query, "videos": videos, "count": len(videos), "cursor": cursor + len(videos)} except Exception as e: print(f"Error searching for {query}: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/core/playwright_manager.py b/backend/core/playwright_manager.py index 97ead52..5a4f006 100644 --- a/backend/core/playwright_manager.py +++ b/backend/core/playwright_manager.py @@ -7,10 +7,11 @@ Uses Playwright to: 3. Intercept /item_list API responses (instead of scraping HTML) """ -import asyncio -import json import os -from typing import List, Dict, Optional +import json +import asyncio +import traceback +from typing import List, Dict, Optional, Any from playwright.async_api import async_playwright, Response, Browser, BrowserContext try: @@ -52,51 +53,70 @@ class PlaywrightManager: _vnc_active = False @staticmethod - def parse_json_credentials(json_creds: dict) -> tuple[List[dict], str]: + def parse_json_credentials(json_creds: Any) -> tuple[List[dict], str]: """ - Parse JSON credentials in the format: - { - "http": { - "headers": {"User-Agent": "...", "Cookie": "..."}, - "cookies": {"sessionid": "...", "ttwid": "..."} - } - } + Parse JSON credentials. Supports: + 1. Array format: [{"name": "...", "value": "..."}, ...] + 2. http object format: {"http": {"headers": {...}, "cookies": {...}}} Returns: (cookies_list, user_agent) """ cookies = [] user_agent = PlaywrightManager.DEFAULT_USER_AGENT - http_data = json_creds.get("http", {}) - headers = http_data.get("headers", {}) - cookies_dict = http_data.get("cookies", {}) - - # Get User-Agent from headers - if "User-Agent" in headers: - user_agent = headers["User-Agent"] - - # Parse cookies from the cookies dict (preferred) - if cookies_dict: - for name, value in cookies_dict.items(): - cookies.append({ - "name": name, - "value": str(value), - "domain": ".tiktok.com", - "path": "/" - }) - # Fallback: parse from Cookie header string - elif "Cookie" in headers: - cookie_str = headers["Cookie"] - for part in cookie_str.split(";"): - part = part.strip() - if "=" in part: - name, value = part.split("=", 1) + # Handle array format (Cookie-Editor) + if isinstance(json_creds, list): + for c in json_creds: + if isinstance(c, dict) and "name" in c and "value" in c: + cookie = { + "name": c["name"], + "value": str(c["value"]), + "domain": c.get("domain") or ".tiktok.com", + "path": c.get("path") or "/", + "secure": c.get("secure", True), + "httpOnly": c.get("httpOnly", False) + } + if "sameSite" in c and c["sameSite"]: + # Playwright expects "Strict", "Lax", or "None" + ss = str(c["sameSite"]).capitalize() + if ss in ["Strict", "Lax", "None"]: + cookie["sameSite"] = ss + + cookies.append(cookie) + return cookies, user_agent + + # Handle object format + if isinstance(json_creds, dict): + http_data = json_creds.get("http", {}) + headers = http_data.get("headers", {}) + cookies_dict = http_data.get("cookies", {}) + + # Get User-Agent from headers + if "User-Agent" in headers: + user_agent = headers["User-Agent"] + + # Parse cookies from the cookies dict (preferred) + if cookies_dict: + for name, value in cookies_dict.items(): cookies.append({ - "name": name.strip(), - "value": value.strip(), + "name": name, + "value": str(value), "domain": ".tiktok.com", "path": "/" }) + # Fallback: parse from Cookie header string + elif "Cookie" in headers: + cookie_str = headers["Cookie"] + for part in cookie_str.split(";"): + part = part.strip() + if "=" in part: + name, value = part.split("=", 1) + cookies.append({ + "name": name.strip(), + "value": value.strip(), + "domain": ".tiktok.com", + "path": "/" + }) return cookies, user_agent @@ -109,14 +129,18 @@ class PlaywrightManager: if os.path.exists(COOKIES_FILE): try: with open(COOKIES_FILE, "r") as f: - cookie_dict = json.load(f) - for name, value in cookie_dict.items(): - cookies.append({ - "name": name, - "value": str(value), - "domain": ".tiktok.com", - "path": "/" - }) + data = json.load(f) + if isinstance(data, list): + cookies = data + elif isinstance(data, dict): + # Backward compatibility or simple dict format + for name, value in data.items(): + cookies.append({ + "name": name, + "value": str(value), + "domain": ".tiktok.com", + "path": "/" + }) except Exception as e: print(f"Error loading cookies: {e}") @@ -131,13 +155,14 @@ class PlaywrightManager: return cookies, user_agent @staticmethod - def save_credentials(cookies: dict, user_agent: str): + def save_credentials(cookies: List[dict] | dict, user_agent: str = None): """Save cookies and user agent to files.""" with open(COOKIES_FILE, "w") as f: json.dump(cookies, f, indent=2) - with open(USER_AGENT_FILE, "w") as f: - json.dump({"user_agent": user_agent}, f) + if user_agent: + with open(USER_AGENT_FILE, "w") as f: + json.dump({"user_agent": user_agent}, f) @classmethod async def start_vnc_login(cls) -> dict: @@ -432,16 +457,16 @@ class PlaywrightManager: @staticmethod async def intercept_feed(cookies: List[dict] = None, user_agent: str = None, scroll_count: int = 5) -> List[dict]: - """ - Navigate to TikTok For You page and intercept the /item_list API response. - - Args: - cookies: Optional list of cookies - user_agent: Optional user agent - scroll_count: Number of times to scroll to fetch more videos (0 = initial load only) + """Navigate to TikTok feed and intercept API responses.""" + try: + return await PlaywrightManager._intercept_feed_impl(cookies, user_agent, scroll_count) + except Exception as e: + print(f"DEBUG: Error in intercept_feed: {e}") + print(traceback.format_exc()) + raise e - Returns: List of video objects - """ + @staticmethod + async def _intercept_feed_impl(cookies: List[dict] = None, user_agent: str = None, scroll_count: int = 5) -> List[dict]: if not cookies: cookies, user_agent = PlaywrightManager.load_stored_credentials() @@ -487,7 +512,16 @@ class PlaywrightManager: ) context = await browser.new_context(user_agent=user_agent) - await context.add_cookies(cookies) + + if cookies: + try: + await context.add_cookies(cookies) + print(f"DEBUG: Applied {len(cookies)} cookies to browser context") + except Exception as e: + print(f"DEBUG: Error applying cookies: {e}") + if len(cookies) > 0: + print(f"DEBUG: Sample cookie: {cookies[0]}") + raise e page = await context.new_page() await stealth_async(page) @@ -694,10 +728,17 @@ class PlaywrightManager: return captured_videos @staticmethod - async def search_videos(query: str, cookies: list, user_agent: str = None, limit: int = 12) -> list: + async def search_videos(query: str, cookies: list, user_agent: str = None, limit: int = 20, cursor: int = 0) -> list: """ Search for videos by keyword or hashtag. Uses Playwright to intercept TikTok search results API. + + Args: + query: Search query + cookies: Auth cookies + user_agent: Browser user agent + limit: Max videos to capture in this batch + cursor: Starting offset for pagination """ from playwright.async_api import async_playwright, Response from urllib.parse import quote @@ -709,7 +750,7 @@ class PlaywrightManager: print("DEBUG: No cookies available for search") return [] - print(f"DEBUG: Searching for '{query}'...") + print(f"DEBUG: Searching for '{query}' (limit={limit}, cursor={cursor})...") captured_videos = [] @@ -728,13 +769,17 @@ class PlaywrightManager: items = data.get("itemList", []) or data.get("data", []) or data.get("item_list", []) for item in items: + # If we have enough for this specific batch, we don't need more if len(captured_videos) >= limit: break + video_data = PlaywrightManager._extract_video_data(item) if video_data: - captured_videos.append(video_data) + # Avoid duplicates within the same capture session + if not any(v['id'] == video_data['id'] for v in captured_videos): + captured_videos.append(video_data) - print(f"DEBUG: Captured {len(items)} videos from search API") + print(f"DEBUG: Captured {len(items)} videos from search API (Total batch: {len(captured_videos)})") except Exception as e: print(f"DEBUG: Error parsing search API response: {e}") @@ -755,22 +800,40 @@ class PlaywrightManager: try: # Navigate to TikTok search page search_url = f"https://www.tiktok.com/search/video?q={quote(query)}" - await page.goto(search_url, wait_until="networkidle", timeout=30000) + try: + await page.goto(search_url, wait_until="domcontentloaded", timeout=15000) + except: + print("DEBUG: Navigation timeout, proceeding anyway") - # Wait for videos to load + # Wait for initial results await asyncio.sleep(3) - # Scroll to trigger more loading - for _ in range(2): - await page.evaluate("window.scrollBy(0, 800)") - await asyncio.sleep(1) + # Scroll based on cursor to reach previous results and then capture new ones + # Each scroll typically loads 12-20 items + # We scroll more as the cursor increases + scroll_count = (cursor // 10) + 1 + # Limit total scrolls to avoid hanging + scroll_count = min(scroll_count, 10) + + for i in range(scroll_count): + await page.evaluate("window.scrollBy(0, 1500)") + await asyncio.sleep(1.5) + + # After reaching the offset, scroll a bit more to trigger the specific batch capture + batch_scrolls = (limit // 10) + 2 # Add extra scrolls to be safe + for _ in range(batch_scrolls): + await page.evaluate("window.scrollBy(0, 2000)") # Larger scroll + await asyncio.sleep(1.0) # Faster scroll cadence + + # Wait a bit after scrolling for all responses to settle + await asyncio.sleep(2.5) except Exception as e: print(f"DEBUG: Error during search: {e}") await browser.close() - print(f"DEBUG: Total captured search videos: {len(captured_videos)}") + print(f"DEBUG: Total captured search videos in this batch: {len(captured_videos)}") return captured_videos @staticmethod diff --git a/backend/main.py b/backend/main.py index 902b19d..7df242b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,16 +5,40 @@ from fastapi.responses import FileResponse from contextlib import asynccontextmanager from pathlib import Path from api.routes import auth, feed, download, following, config, user +import sys +import asyncio + +# Force Proactor on Windows for Playwright +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) @asynccontextmanager async def lifespan(app: FastAPI): """Startup and shutdown events.""" print("🚀 Starting PureStream API (Network Interception Mode)...") + import asyncio + try: + loop = asyncio.get_running_loop() + print(f"DEBUG: Running event loop: {type(loop)}") + except Exception as e: + print(f"DEBUG: Could not get running loop: {e}") yield print("👋 Shutting down PureStream API...") +import asyncio +import sys + app = FastAPI(title="PureStream API", version="2.0.0", lifespan=lifespan) +if __name__ == "__main__": + if sys.platform == "win32": + try: + loop = asyncio.get_event_loop() + print(f"DEBUG: Current event loop: {type(loop)}") + except: + print("DEBUG: No event loop yet") + + # CORS middleware app.add_middleware( CORSMiddleware, @@ -54,5 +78,5 @@ if FRONTEND_DIR.exists(): if __name__ == "__main__": import uvicorn - uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=True) + uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio") diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 7dc077e..aedad13 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -3,7 +3,7 @@ import { VideoPlayer } from './VideoPlayer'; import type { Video, UserProfile } from '../types'; import axios from 'axios'; import { API_BASE_URL } from '../config'; -import { Home, Users, Search, X, Plus } from 'lucide-react'; +import { Search, X, Plus } from 'lucide-react'; import { videoPrefetcher } from '../utils/videoPrefetch'; import { feedLoader } from '../utils/feedLoader'; @@ -104,8 +104,11 @@ export const Feed: React.FC = () => { const [activeTab, setActiveTab] = useState('foryou'); const [videos, setVideos] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); - const [error, setError] = useState(null); - const [showAdvanced, setShowAdvanced] = useState(false); + const [searchCursor, setSearchCursor] = useState(0); + const [searchHasMore, setSearchHasMore] = useState(true); + const [isInSearchPlayback, setIsInSearchPlayback] = useState(false); + const [originalVideos, setOriginalVideos] = useState([]); + const [originalIndex, setOriginalIndex] = useState(0); const [jsonInput, setJsonInput] = useState(''); const containerRef = useRef(null); @@ -116,12 +119,17 @@ export const Feed: React.FC = () => { // Suggested profiles with real data const [suggestedProfiles, setSuggestedProfiles] = useState([]); const [loadingProfiles, setLoadingProfiles] = useState(false); - const [suggestedLimit, setSuggestedLimit] = useState(12); // Lazy load - start with 12 + const [suggestedLimit, setSuggestedLimit] = useState(12); + const [showHeader, setShowHeader] = useState(false); + const [isFollowingFeed, setIsFollowingFeed] = useState(false); + // Lazy load - start with 12 // Search state const [searchInput, setSearchInput] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); + const [error, setError] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); // Global mute state - persists across video scrolling const [isMuted, setIsMuted] = useState(true); @@ -131,6 +139,7 @@ export const Feed: React.FC = () => { const touchEnd = useRef(null); const minSwipeDistance = 50; + // Touch Handling (Mobile) const onTouchStart = (e: React.TouchEvent) => { touchEnd.current = null; touchStart.current = e.targetTouches[0].clientX; @@ -141,18 +150,51 @@ export const Feed: React.FC = () => { }; const onTouchEnd = () => { + handleSwipeEnd(); + }; + + // Mouse Handling (Desktop) + const onMouseDown = (e: React.MouseEvent) => { + touchEnd.current = null; + touchStart.current = e.clientX; + }; + + const onMouseMove = (e: React.MouseEvent) => { + if (e.buttons === 1) { // Only track if left button held + touchEnd.current = e.clientX; + } + }; + + const onMouseUp = () => { + if (touchStart.current) { + // Handle click vs swipe + // If minimal movement, treat as click/tap (handled by onClick elsewhere, but for header toggle we need it here?) + // Actually, onMouseUp is better for swipe end. + handleSwipeEnd(); + } + touchStart.current = null; + touchEnd.current = null; + }; + + const handleSwipeEnd = () => { if (!touchStart.current || !touchEnd.current) return; - const distance = touchStart.current - touchEnd.current; - const isLeftSwipe = distance > minSwipeDistance; - const isRightSwipe = distance < -minSwipeDistance; + + const distanceX = touchStart.current - touchEnd.current; + const isLeftSwipe = distanceX > minSwipeDistance; + const isRightSwipe = distanceX < -minSwipeDistance; if (isLeftSwipe) { - if (activeTab === 'foryou') setActiveTab('following'); - else if (activeTab === 'following') setActiveTab('search'); + if (activeTab === 'foryou') { setActiveTab('following'); setShowHeader(true); } + else if (activeTab === 'following') { setActiveTab('search'); setShowHeader(true); } + } else if (isRightSwipe) { + if (activeTab === 'search') { setActiveTab('following'); setShowHeader(true); } + else if (activeTab === 'following') { setActiveTab('foryou'); setShowHeader(true); } + } else { + // Minor movement - Do nothing (Tap is handled by video click) } - if (isRightSwipe) { - if (activeTab === 'search') setActiveTab('following'); - else if (activeTab === 'following') setActiveTab('foryou'); + + if (activeTab === 'foryou') { + setTimeout(() => setShowHeader(false), 3000); } }; @@ -209,6 +251,21 @@ export const Feed: React.FC = () => { prefetch(); }, [currentIndex, videos, activeTab]); + // Scrolls to the current index when the videos list changes or when we enter/exit search playback + // This fixes the "blur screen" bug where the previous scroll position persisted after swapping video lists + useEffect(() => { + if (activeTab === 'foryou' && containerRef.current && videos.length > 0) { + const targetScroll = currentIndex * containerRef.current.clientHeight; + // Only scroll if significantly off (allow small manual adjustments) + if (Math.abs(containerRef.current.scrollTop - targetScroll) > 50) { + containerRef.current.scrollTo({ + top: targetScroll, + behavior: 'auto' // Instant jump to prevent weird visual sliding on list swap + }); + } + } + }, [videos, activeTab, isInSearchPlayback]); // Dependencies needed to trigger on list swap + const loadSuggestedProfiles = async () => { setLoadingProfiles(true); try { @@ -257,10 +314,28 @@ export const Feed: React.FC = () => { const loadFollowing = async () => { try { - const res = await axios.get(`${API_BASE_URL}/following`); - setFollowing(res.data); - } catch (err) { - console.error('Failed to load following'); + const { data } = await axios.get(`${API_BASE_URL}/following`); + setFollowing(data); + + // If on following tab, load actual feed instead of just profiles + if (activeTab === 'following') { + setIsFetching(true); + try { + const res = await axios.get(`${API_BASE_URL}/following/feed`); + if (res.data && res.data.length > 0) { + setVideos(res.data); + setCurrentIndex(0); + setIsFollowingFeed(true); + setActiveTab('foryou'); // Switch to feed view but with following content + } + } catch (e) { + console.error('Error loading following feed:', e); + } finally { + setIsFetching(false); + } + } + } catch (error) { + console.error('Error loading following list:', error); } }; @@ -396,225 +471,66 @@ export const Feed: React.FC = () => { setViewState('login'); }; - // Direct username search - bypasses state update delay - // Falls back to keyword search if user not found + // Direct username search const searchByUsername = async (username: string) => { setSearchInput(`@${username}`); setActiveTab('search'); - setIsSearching(true); - setSearchResults([]); - - try { - const res = await axios.get(`${API_BASE_URL}/user/videos?username=${username}&limit=12`); - const userVideos = res.data.videos as Video[]; - - if (userVideos.length > 0) { - setSearchResults(userVideos); - } else { - // No videos from user profile, try keyword search - console.log(`No videos from @${username}, trying keyword search...`); - await fallbackToKeywordSearch(username); - } - } catch (err) { - console.error('Error fetching user videos, trying keyword search:', err); - // User not found or error - fallback to keyword search - await fallbackToKeywordSearch(username); - } finally { - setIsSearching(false); - } + handleSearch(false, `@${username}`); }; - // Fallback search when user profile fails - const fallbackToKeywordSearch = async (keyword: string) => { - try { - const res = await axios.get(`${API_BASE_URL}/user/search?query=${encodeURIComponent(keyword)}&limit=12`); - const searchVideos = res.data.videos as Video[]; - - if (searchVideos.length > 0) { - setSearchResults(searchVideos); - } else { - // Still no results - show friendly message - setSearchResults([{ - id: `no-results-${keyword}`, - url: '', - author: 'search', - description: `No videos found for "${keyword}". Try a different search term.` - }]); - } - } catch (searchErr) { - console.error('Keyword search also failed:', searchErr); - setSearchResults([{ - id: `search-error`, - url: '', - author: 'search', - description: `Search is temporarily unavailable. Please try again later.` - }]); - } - }; - - // Direct keyword search - bypasses state update delay + // Direct keyword search const searchByKeyword = async (keyword: string) => { setSearchInput(keyword); setActiveTab('search'); + handleSearch(false, keyword); + }; + + const handleSearch = async (isMore = false, overrideInput?: string) => { + const inputToSearch = overrideInput || searchInput; + if (!inputToSearch.trim() || isSearching) return; + setIsSearching(true); - setSearchResults([]); + setError(null); try { - const res = await axios.get(`${API_BASE_URL}/user/search?query=${encodeURIComponent(keyword)}&limit=12`); - const searchVideos = res.data.videos as Video[]; + const cursor = isMore ? searchCursor : 0; + // "Search must show at least 50 result" - fetching 50 at a time with infinite scroll + const limit = 50; - if (searchVideos.length > 0) { - setSearchResults(searchVideos); - } else { - setSearchResults([{ - id: `no-results`, - url: '', - author: 'search', - description: `No videos found for "${keyword}"` - }]); + let endpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(inputToSearch)}&limit=${limit}&cursor=${cursor}`; + + // If direct username search + if (inputToSearch.startsWith('@')) { + endpoint = `${API_BASE_URL}/user/videos?username=${inputToSearch.substring(1)}&limit=${limit}`; } + + const { data } = await axios.get(endpoint); + const newVideos = data.videos || []; + + if (isMore) { + setSearchResults(prev => [...prev, ...newVideos]); + } else { + setSearchResults(newVideos); + } + + setSearchCursor(data.cursor || 0); + // If we got results, assume there's more (TikTok has endless content) + // unless the count is very small (e.g. < 5) which might indicate end + setSearchHasMore(newVideos.length >= 5); + } catch (err) { - console.error('Error searching:', err); - setSearchResults([{ - id: `error-search`, - url: '', - author: 'search', - description: `Search failed` - }]); + console.error('Search failed:', err); + setError('Search failed. Please try again.'); } finally { setIsSearching(false); } }; - const handleSearch = async () => { - if (!searchInput.trim()) return; - setIsSearching(true); - let input = searchInput.trim(); - const results: Video[] = []; - - // ========== PARSE INPUT TYPE ========== - - // Type 1: Full TikTok video URL (tiktok.com/@user/video/123) - const videoUrlMatch = input.match(/tiktok\.com\/@([\w.]+)\/video\/(\d+)/); - if (videoUrlMatch) { - const [, author, videoId] = videoUrlMatch; - results.push({ - id: videoId, - url: input.startsWith('http') ? input : `https://www.${input}`, - author: author, - description: `Video ${videoId} by @${author}` - }); + const handleSearchScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + if (scrollHeight - scrollTop <= clientHeight + 100 && searchHasMore && !isSearching) { + handleSearch(true); } - - // Type 2: Short share links (vm.tiktok.com, vt.tiktok.com) - else if (input.includes('vm.tiktok.com') || input.includes('vt.tiktok.com')) { - // These are short links - add as-is, backend will resolve - const shortId = input.split('/').pop() || 'unknown'; - results.push({ - id: `short-${shortId}`, - url: input.startsWith('http') ? input : `https://${input}`, - author: 'unknown', - description: 'Shared TikTok video (click to watch)' - }); - } - - // Type 3: Username (@user or just user) - Fetch user's videos - else if (input.startsWith('@') || /^[\w.]+$/.test(input)) { - const username = input.replace('@', ''); - - // Show loading state - results.push({ - id: `loading-${username}`, - url: '', - author: username, - description: `⏳ Loading videos from @${username}...` - }); - setSearchResults(results); - - // Fetch user's videos from backend - try { - const res = await axios.get(`${API_BASE_URL}/user/videos?username=${username}&limit=12`); - const userVideos = res.data.videos as Video[]; - - if (userVideos.length > 0) { - // Replace loading with actual videos - setSearchResults(userVideos); - setIsSearching(false); - return; - } else { - // No videos found - setSearchResults([{ - id: `no-videos-${username}`, - url: '', - author: username, - description: `No videos found for @${username}` - }]); - setIsSearching(false); - return; - } - } catch (err) { - console.error('Error fetching user videos:', err); - // Fallback message - setSearchResults([{ - id: `error-${username}`, - url: '', - author: username, - description: `Could not fetch videos` - }]); - setIsSearching(false); - return; - } - } - - // Type 4: Hashtag (#trend) or Generic search term - use search API - else { - // Show loading for keyword search - results.push({ - id: `loading-search`, - url: '', - author: 'search', - description: `Searching for "${input}"...` - }); - setSearchResults(results); - - // Fetch videos using keyword search API - try { - const res = await axios.get(`${API_BASE_URL}/user/search?query=${encodeURIComponent(input)}&limit=12`); - const searchVideos = res.data.videos as Video[]; - - if (searchVideos.length > 0) { - setSearchResults(searchVideos); - setIsSearching(false); - return; - } else { - setSearchResults([{ - id: `no-results`, - url: '', - author: 'search', - description: `No videos found for "${input}"` - }]); - setIsSearching(false); - return; - } - } catch (err) { - console.error('Error searching:', err); - setSearchResults([{ - id: `error-search`, - url: '', - author: 'search', - description: `Search failed. Try a different term.` - }]); - setIsSearching(false); - return; - } - } - - setSearchResults(results); - setIsSearching(false); - - // Log for debugging - console.log('Search input:', input); - console.log('Search results:', results); }; // ========== LOGIN VIEW ========== @@ -760,8 +676,6 @@ export const Feed: React.FC = () => { ); } - - // ========== FEED VIEW WITH TABS ========== return (
{ onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} + onMouseDown={onMouseDown} + onMouseMove={onMouseMove} + onMouseUp={onMouseUp} > {/* Tab Navigation */} -
-
+ {/* Tab Navigation - Hidden by default, show on toggle/swipe */} +
+
{/* Logout Button - Left Corner Icon */} - + {/* Logout Button / Back Button Logic */} + {/* "make the go back button on the top right conner, replace, swith from the log out button" */} + + {!isInSearchPlayback ? ( + + ) : null} {/* FOR YOU TAB */}
{
- {/* SEARCH TAB - Minimal Style matching Following */} + {/* SEARCH TAB */}
+ }`} + onScroll={handleSearchScroll} + >
{/* Minimal Search Input */}
@@ -1028,7 +954,7 @@ export const Feed: React.FC = () => { disabled={isSearching} /> - {/* Subtle hint dropdown */}

@username · video link · keyword

{/* Loading Animation with Quote */} - {isSearching && ( + {isSearching && searchResults.length === 0 && (

"{INSPIRATION_QUOTES[Math.floor(Math.random() * INSPIRATION_QUOTES.length)].text}"

-

- — {INSPIRATION_QUOTES[Math.floor(Math.random() * INSPIRATION_QUOTES.length)].author} -

)} - {/* Empty State - Following-style layout */} + {/* Empty State / Suggestions */} {!isSearching && searchResults.length === 0 && ( <> - {/* Trending - 2 columns */} + {/* Trending */}

Trending

@@ -1078,62 +1000,26 @@ export const Feed: React.FC = () => { ))}
- - {/* Quick Search - Account avatars */} -
-

Popular

-
- {(suggestedProfiles.length > 0 ? suggestedProfiles : SUGGESTED_ACCOUNTS.map(a => ({ username: a.username.replace('@', '') }))).slice(0, 4).map((profile: UserProfile | { username: string }) => { - const username = 'username' in profile ? profile.username : ''; - return ( - - ); - })} -
-
)} {/* Search Results */} - {!isSearching && searchResults.length > 0 && ( + {searchResults.length > 0 && (
- {/* Results Header with Creator & Follow */}
-
- {searchResults.length} videos - {searchResults[0]?.author && searchResults[0].author !== 'search' && ( + {searchResults.length} videos +
+ {searchInput.startsWith('@') && ( )} -
-
- {/* Play All Button */} -
- {/* Video Grid */}
{searchResults.map((video) => (
{ if (!video.url) return; - // Load ALL search results into the feed, starting from clicked video + setOriginalVideos(videos); + setOriginalIndex(currentIndex); const playableVideos = searchResults.filter(v => v.url); - if (playableVideos.length > 0) { - setVideos(playableVideos); - // Set current index to the clicked video's position in playable videos - const newIndex = playableVideos.findIndex(v => v.id === video.id); - setCurrentIndex(newIndex >= 0 ? newIndex : 0); - setActiveTab('foryou'); - } + setVideos(playableVideos); + const newIndex = playableVideos.findIndex(v => v.id === video.id); + setCurrentIndex(newIndex >= 0 ? newIndex : 0); + setIsInSearchPlayback(true); + setActiveTab('foryou'); }} > - {/* Thumbnail with loading placeholder */} {video.thumbnail ? ( {video.author} { - (e.target as HTMLImageElement).style.display = 'none'; - }} + className="w-full h-full object-cover" /> ) : (
- {video.url ? ( -
- ) : ( - ℹ️ - )} -
- )} - - {/* Overlay with author */} - {video.url && ( -
-

@{video.author}

-
- )} - - {/* Message for non-playable */} - {!video.url && video.description && ( -
-

{video.description}

+
)} +
+

@{video.author}

+
))}
@@ -1219,6 +1075,21 @@ export const Feed: React.FC = () => {
+ {/* In-Search Back Button */} + {isInSearchPlayback && ( + + )}
); }; diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index 0f16c48..0e0a774 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -41,7 +41,7 @@ export const VideoPlayer: React.FC = ({ const progressBarRef = useRef(null); const [isPaused, setIsPaused] = useState(false); const [showControls, setShowControls] = useState(false); - const [objectFit, setObjectFit] = useState<'cover' | 'contain'>('contain'); + const [objectFit] = useState<'cover' | 'contain'>('contain'); const [progress, setProgress] = useState(0); const [duration, setDuration] = useState(0); const [isSeeking, setIsSeeking] = useState(false); @@ -112,12 +112,15 @@ export const VideoPlayer: React.FC = ({ return () => window.removeEventListener('keydown', handleKeyDown); }, [isActive]); + const [showSidebar, setShowSidebar] = useState(false); + // Reset fallback and loading state when video changes useEffect(() => { setUseFallback(false); setIsLoading(true); // Show loading for new video setCodecError(false); // Reset codec error for new video setCachedUrl(null); + setShowSidebar(false); // Reset sidebar for new video const checkCache = async () => { const cached = await videoCache.get(video.url); @@ -209,9 +212,6 @@ export const VideoPlayer: React.FC = ({ } }; - const toggleObjectFit = () => { - setObjectFit(prev => prev === 'contain' ? 'cover' : 'contain'); - }; const toggleMute = (e: React.MouseEvent | React.TouchEvent) => { e.stopPropagation(); // Prevent video tap @@ -247,6 +247,17 @@ export const VideoPlayer: React.FC = ({ touches.forEach((touch, index) => { const timeSinceLastTap = now - lastTapRef.current; + // Swipe Left from right edge check (to open sidebar) + const rectWidth = rect.width; + const startX = touch.clientX - rect.left; + + // If touch starts near right edge (last 15% of screen) + if (startX > rectWidth * 0.85 && touches.length === 1) { + // We'll handle the actual swipe logic in touchMove/End, + // but setting a flag or using the existing click logic might be easier. + // For now, let's allow a simple tap on the edge to toggle too, as per existing click logic. + } + // Show heart if: // 1. Double tap (< 400ms) // 2. OR Multi-touch (2+ fingers) @@ -295,6 +306,13 @@ export const VideoPlayer: React.FC = ({ const rect = containerRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; + + // If clicked on the right edge, toggle sidebar + if (x > rect.width * 0.9) { + setShowSidebar(prev => !prev); + return; + } + const heartId = Date.now() + Math.random(); setHearts(prev => [...prev, { id: heartId, x, y }]); setTimeout(() => { @@ -460,15 +478,15 @@ export const VideoPlayer: React.FC = ({ )}
- {/* Side Controls */} + {/* Side Controls - Hidden by default, reveal on swipe/interaction */}
{/* Follow Button */} {onFollow && ( - {/* Mute Toggle */}