diff --git a/.dockerignore.bak b/.dockerignore.bak deleted file mode 100644 index 172e234..0000000 --- a/.dockerignore.bak +++ /dev/null @@ -1,21 +0,0 @@ -frontend/node_modules -frontend/.next -frontend/dist -frontend/build -backend/bin -backend/logs -node_modules -.next -.git -.DS_Store -videos -data -venv -.gemini -tmp* -*.exe -*.mac -*-mac -*-new -page.html -CUsersAdminDocumentskv-tubepage.html diff --git a/CUsersAdminDocumentskv-tubepage.html b/CUsersAdminDocumentskv-tubepage.html deleted file mode 100644 index e8febe8..0000000 --- a/CUsersAdminDocumentskv-tubepage.html +++ /dev/null @@ -1,8 +0,0 @@ -KV-Tube
KV-Tube
\ No newline at end of file diff --git a/CUsersAdminDocumentskv-tubetemp.js b/CUsersAdminDocumentskv-tubetemp.js deleted file mode 100644 index 7232fc4..0000000 --- a/CUsersAdminDocumentskv-tubetemp.js +++ /dev/null @@ -1 +0,0 @@ -cat: can't open '/app/frontend/.next/required-server-files.js': No such file or directory diff --git a/Dockerfile b/Dockerfile index 48245d1..a11ac83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,14 @@ # ---- Backend Builder ---- FROM golang:1.25-alpine AS backend-builder ENV GOTOOLCHAIN=local +ENV GOPROXY=https://proxy.golang.org,direct WORKDIR /app RUN apk add --no-cache git gcc musl-dev COPY backend/go.mod backend/go.sum ./ RUN (echo "module kvtube-go"; echo ""; echo "go 1.24.0"; tail -n +4 go.mod) > go.mod.new && mv go.mod.new go.mod && go mod tidy RUN go mod download COPY backend/ ./ -RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o kv-tube . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o kv-tube . # ---- Frontend Builder ---- FROM node:20-alpine AS frontend-deps diff --git a/README.md b/README.md index 12771d3..a95f94e 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ We recommend using **Container Manager** (DSM 7.2+) or **Docker** (DSM 6/7.1) fo ### 1. Prerequisites - **Container Manager** or **Docker** package installed from Package Center. -- Ensure ports `5011` (frontend) and `8080` (backend API) are available on your NAS. +- Ensure ports `5011` (frontend) and `8981` (backend API) are available on your NAS. - Create a folder named `kv-tube` in your `docker` shared folder (e.g., `/volume1/docker/kv-tube`). ### 2. Using Container Manager (Recommended) @@ -75,7 +75,7 @@ services: restart: unless-stopped ports: - "5011:3000" - - "8080:8080" + - "8981:8080" volumes: - ./data:/app/data environment: @@ -90,7 +90,7 @@ services: ### 3. Accessing the App The application will be accessible at: - **Frontend**: `http://:5011` -- **Backend API**: `http://:8080` +- **Backend API**: `http://:8981` - **Mobile Users**: Add to Home Screen via Safari for the full PWA experience with background playback. ### 4. Volume Permissions (If Needed) diff --git a/backend/backend.log b/backend/backend.log new file mode 100644 index 0000000..309eeb1 --- /dev/null +++ b/backend/backend.log @@ -0,0 +1,77 @@ +2026/03/26 07:59:34 Database initialized successfully at ../data/kvtube.db +[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. + +[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. + - using env: export GIN_MODE=release + - using code: gin.SetMode(gin.ReleaseMode) + +[GIN-debug] GET /api/health --> kvtube-go/routes.SetupRouter.func2 (4 handlers) +[GIN-debug] GET /api/search --> kvtube-go/routes.handleSearch (4 handlers) +[GIN-debug] GET /api/trending --> kvtube-go/routes.handleTrending (4 handlers) +[GIN-debug] GET /api/video/:id --> kvtube-go/routes.handleGetVideoInfo (4 handlers) +[GIN-debug] GET /api/video/:id/qualities --> kvtube-go/routes.handleGetQualities (4 handlers) +[GIN-debug] GET /api/video/:id/related --> kvtube-go/routes.handleRelatedVideos (4 handlers) +[GIN-debug] GET /api/video/:id/comments --> kvtube-go/routes.handleComments (4 handlers) +[GIN-debug] GET /api/video/:id/download --> kvtube-go/routes.handleDownload (4 handlers) +[GIN-debug] GET /api/channel/info --> kvtube-go/routes.handleChannelInfo (4 handlers) +[GIN-debug] GET /api/channel/videos --> kvtube-go/routes.handleChannelVideos (4 handlers) +[GIN-debug] POST /api/history --> kvtube-go/routes.handlePostHistory (4 handlers) +[GIN-debug] GET /api/history --> kvtube-go/routes.handleGetHistory (4 handlers) +[GIN-debug] GET /api/suggestions --> kvtube-go/routes.handleGetSuggestions (4 handlers) +[GIN-debug] POST /api/subscribe --> kvtube-go/routes.handleSubscribe (4 handlers) +[GIN-debug] DELETE /api/subscribe --> kvtube-go/routes.handleUnsubscribe (4 handlers) +[GIN-debug] GET /api/subscribe --> kvtube-go/routes.handleCheckSubscription (4 handlers) +[GIN-debug] GET /api/subscriptions --> kvtube-go/routes.handleGetSubscriptions (4 handlers) +2026/03/26 07:59:34 KV-Tube Go Backend starting on port 8080... +[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value. +Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details. +[GIN-debug] Listening and serving HTTP on :8080 +[GIN] 2026/03/26 - 07:59:42 | 200 | 1.383093916s | ::1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=2" +[GIN] 2026/03/26 - 08:00:04 | 200 | 2.159542ms | 127.0.0.1 | GET "/api/subscriptions" +[GIN] 2026/03/26 - 08:00:04 | 200 | 6.37675ms | 127.0.0.1 | GET "/api/subscriptions" +[GIN] 2026/03/26 - 08:00:06 | 200 | 2.0681615s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25" +[GIN] 2026/03/26 - 08:00:06 | 200 | 2.127086416s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25" +[GIN] 2026/03/26 - 08:00:08 | 200 | 1.710409125s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25" +[GIN] 2026/03/26 - 08:00:08 | 200 | 1.7510695s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25" +[GIN] 2026/03/26 - 08:00:09 | 200 | 1.762290709s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25" +[GIN] 2026/03/26 - 08:00:10 | 200 | 1.843944666s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25" +[GIN] 2026/03/26 - 08:00:11 | 200 | 1.952155291s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25" +[GIN] 2026/03/26 - 08:00:11 | 200 | 1.786566833s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25" +[GIN] 2026/03/26 - 08:00:13 | 200 | 1.371072416s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25" +[GIN] 2026/03/26 - 08:00:13 | 200 | 1.403493s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25" +[GIN] 2026/03/26 - 08:00:16 | 200 | 1.530959ms | 127.0.0.1 | GET "/api/subscriptions" +[GIN] 2026/03/26 - 08:00:17 | 200 | 4.158150916s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25" +[GIN] 2026/03/26 - 08:00:17 | 200 | 4.167733833s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25" +[GIN] 2026/03/26 - 08:00:19 | 200 | 3.131014s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25" +[GIN] 2026/03/26 - 08:00:21 | 200 | 1.744236s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25" +[GIN] 2026/03/26 - 08:00:22 | 200 | 1.842939625s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25" +[GIN] 2026/03/26 - 08:00:24 | 200 | 1.716655791s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25" +[GIN] 2026/03/26 - 08:00:26 | 200 | 1.886865792s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25" +[GIN] 2026/03/26 - 08:00:28 | 200 | 2.076937541s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25" +[GIN] 2026/03/26 - 08:00:30 | 200 | 1.39052025s | 127.0.0.1 | GET "/api/channel/videos?id=UCuAXFkgsw1L7xaCfnd5JJOw&limit=25" +[GIN] 2026/03/26 - 08:00:50 | 200 | 1.3275ms | 127.0.0.1 | GET "/api/subscriptions" +[GIN] 2026/03/26 - 08:00:51 | 200 | 1.774724625s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25" +[GIN] 2026/03/26 - 08:00:53 | 200 | 1.141853916s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25" +[GIN] 2026/03/26 - 08:00:54 | 200 | 1.30642975s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25" +[GIN] 2026/03/26 - 08:00:55 | 200 | 1.333133042s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25" +[GIN] 2026/03/26 - 08:00:56 | 200 | 1.002488958s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25" +[GIN] 2026/03/26 - 08:00:57 | 200 | 1.093311292s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25" +[GIN] 2026/03/26 - 08:00:59 | 200 | 1.127070708s | 127.0.0.1 | GET "/api/channel/videos?id=UCuAXFkgsw1L7xaCfnd5JJOw&limit=25" +[GIN] 2026/03/26 - 08:01:05 | 200 | 3.016874625s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM" +2026/03/26 08:01:05 GetVideoInfo error: json: cannot unmarshal string into Go value of type services.YtDlpEntry +[GIN] 2026/03/26 - 08:01:05 | 500 | 6.829375ms | 127.0.0.1 | GET "/api/video/X8dM9elNhAM" +[GIN] 2026/03/26 - 08:01:07 | 200 | 1.475739208s | 127.0.0.1 | GET "/api/search?q=ERIK%20H%C6%B0%C6%A1ng%20%E2%80%99B%E1%BA%AFc%20(Kim)%20Bling%E2%80%99%20mix%20compilation&limit=20" +[GIN] 2026/03/26 - 08:01:07 | 200 | 1.595221875s | 127.0.0.1 | GET "/api/search?q=ERIK%20Official%20ERIK%20H%C6%B0%C6%A1ng%20%E2%80%99B%E1%BA%AFc%20(Kim)%20Bling%E2%80%99&limit=20" +[GIN] 2026/03/26 - 08:01:07 | 200 | 2.286978084s | 127.0.0.1 | GET "/api/search?q=music%20mix%20compilation&limit=20" +[GIN] 2026/03/26 - 08:01:09 | 200 | 2.543206084s | 127.0.0.1 | GET "/api/search?q=music%20popular&limit=20" +[GIN] 2026/03/26 - 08:01:10 | 200 | 4.544714333s | 127.0.0.1 | GET "/api/search?q=%20music&limit=20" +[GIN] 2026/03/26 - 08:01:14 | 200 | 4.508561208s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50" +[GIN] 2026/03/26 - 08:01:18 | 200 | 4.418404958s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50" +[GIN] 2026/03/26 - 08:02:59 | 200 | 68.458µs | 127.0.0.1 | GET "/api/health" +2026/03/26 08:03:12 GetVideoInfo error: json: cannot unmarshal string into Go value of type services.YtDlpEntry +[GIN] 2026/03/26 - 08:03:12 | 500 | 27.501791ms | 127.0.0.1 | GET "/api/video/X8dM9elNhAM" +[GIN] 2026/03/26 - 08:03:15 | 200 | 3.318515s | 127.0.0.1 | GET "/api/search?q=music%20mix%20compilation&limit=20" +[GIN] 2026/03/26 - 08:03:17 | 200 | 5.183273125s | 127.0.0.1 | GET "/api/search?q=%20music&limit=20" +[GIN] 2026/03/26 - 08:03:22 | 200 | 5.159578625s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50" +[GIN] 2026/03/26 - 08:03:26 | 200 | 4.13119725s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50" +[GIN] 2026/03/26 - 08:03:56 | 200 | 29.25µs | 127.0.0.1 | GET "/api/health" diff --git a/backend/kv-tube-new b/backend/kv-tube old mode 100644 new mode 100755 similarity index 74% rename from backend/kv-tube-new rename to backend/kv-tube index 87082ee..ca92454 Binary files a/backend/kv-tube-new and b/backend/kv-tube differ diff --git a/backend/kv-tube-mac b/backend/kv-tube-mac deleted file mode 100644 index c7621d9..0000000 Binary files a/backend/kv-tube-mac and /dev/null differ diff --git a/backend/routes/api.go b/backend/routes/api.go index fb09455..8a1f0ce 100644 --- a/backend/routes/api.go +++ b/backend/routes/api.go @@ -1,17 +1,11 @@ package routes import ( - "bufio" - "fmt" - "io" "log" "net/http" - "net/url" "os" - "regexp" "strconv" "strings" - "time" "kvtube-go/services" @@ -47,57 +41,6 @@ func isAllowedOrigin(origin string, allowedOrigins []string) bool { return false } -// isAllowedDomain checks if the URL belongs to allowed domains (YouTube/Google) -func isAllowedDomain(targetURL string) error { - parsedURL, err := url.Parse(targetURL) - if err != nil { - return err - } - - // Allowed domains for video proxy - allowedDomains := []string{ - ".youtube.com", - ".googlevideo.com", - ".ytimg.com", - ".google.com", - ".gstatic.com", - } - - host := strings.ToLower(parsedURL.Hostname()) - - // Check if host matches any allowed domain - for _, domain := range allowedDomains { - if strings.HasSuffix(host, domain) || host == strings.TrimPrefix(domain, ".") { - return nil - } - } - - return fmt.Errorf("domain %s not allowed", host) -} - -// validateSearchQuery ensures search query contains only safe characters -func validateSearchQuery(query string) error { - // Allow alphanumeric, spaces, hyphens, underscores, dots, commas, exclamation marks - safePattern := regexp.MustCompile(`^[a-zA-Z0-9\s\-_.,!]+$`) - if !safePattern.MatchString(query) { - return fmt.Errorf("search query contains invalid characters") - } - if len(query) > 200 { - return fmt.Errorf("search query too long") - } - return nil -} - -// Global HTTP client with connection pooling and timeouts -var httpClient = &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - }, -} - func SetupRouter() *gin.Engine { r := gin.Default() @@ -117,26 +60,26 @@ func SetupRouter() *gin.Engine { c.Next() }) - r.GET("/api/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) - - // API Routes + // API Routes - Using yt-dlp for video operations api := r.Group("/api") { + // Health check + api.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // Video endpoints api.GET("/search", handleSearch) api.GET("/trending", handleTrending) - api.GET("/get_stream_info", handleGetStreamInfo) - api.GET("/download", handleDownload) - api.GET("/download-file", handleDownloadFile) - api.GET("/transcript", handleTranscript) - api.GET("/comments", handleComments) - api.GET("/channel/videos", handleChannelVideos) + api.GET("/video/:id", handleGetVideoInfo) + api.GET("/video/:id/qualities", handleGetQualities) + api.GET("/video/:id/related", handleRelatedVideos) + api.GET("/video/:id/comments", handleComments) + api.GET("/video/:id/download", handleDownload) + + // Channel endpoints api.GET("/channel/info", handleChannelInfo) - api.GET("/related", handleRelatedVideos) - api.GET("/formats", handleGetFormats) - api.GET("/qualities", handleGetQualities) - api.GET("/stream", handleGetStreamByQuality) + api.GET("/channel/videos", handleChannelVideos) // History routes api.POST("/history", handlePostHistory) @@ -150,11 +93,10 @@ func SetupRouter() *gin.Engine { api.GET("/subscriptions", handleGetSubscriptions) } - r.GET("/video_proxy", handleVideoProxy) - return r } +// Video search endpoint func handleSearch(c *gin.Context) { query := c.Query("q") if query == "" { @@ -162,21 +104,15 @@ func handleSearch(c *gin.Context) { return } - // Validate search query for security - if err := validateSearchQuery(query); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - + limitStr := c.Query("limit") limit := 20 - if l := c.Query("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil { - limit = parsed - } + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 { + limit = l } results, err := services.SearchVideos(query, limit) if err != nil { + log.Printf("Search error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"}) return } @@ -184,489 +120,189 @@ func handleSearch(c *gin.Context) { c.JSON(http.StatusOK, results) } +// Trending videos endpoint func handleTrending(c *gin.Context) { - // Basic mock implementation for now - c.JSON(http.StatusOK, gin.H{ - "data": []gin.H{ - { - "id": "trending", - "title": "Currently Trending", - "icon": "fire", - "videos": []gin.H{}, - }, - }, - }) -} + limitStr := c.Query("limit") + limit := 20 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 { + limit = l + } -func handleGetStreamInfo(c *gin.Context) { - videoID := c.Query("v") - if videoID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + // Use popular music search as trending + results, err := services.SearchVideos("popular music trending", limit) + if err != nil { + log.Printf("Trending error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trending videos"}) return } - info, qualities, audioURL, err := services.GetFullStreamData(videoID) + c.JSON(http.StatusOK, results) +} + +// Get video info +func handleGetVideoInfo(c *gin.Context) { + videoID := c.Param("id") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"}) + return + } + + video, err := services.GetVideoInfo(videoID) if err != nil { - log.Printf("GetFullStreamData Error: %v", err) + log.Printf("GetVideoInfo error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"}) return } - // Build quality options for frontend - var qualityOptions []gin.H - bestURL := info.StreamURL - bestHeight := 0 - - for _, q := range qualities { - proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL) - audioProxyURL := "" - if q.AudioURL != "" { - audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL) - } - qualityOptions = append(qualityOptions, gin.H{ - "label": q.Label, - "height": q.Height, - "url": proxyURL, - "audio_url": audioProxyURL, - "is_hls": q.IsHLS, - "has_audio": q.HasAudio, - }) - if q.Height > bestHeight { - bestHeight = q.Height - bestURL = q.URL - } - } - - // If we found qualities, use the best one - streamURL := info.StreamURL - if bestURL != "" { - streamURL = bestURL - } - - proxyURL := "/video_proxy?url=" + url.QueryEscape(streamURL) - - // Get audio URL for the response - audioProxyURL := "" - if audioURL != "" { - audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL) - } - - c.JSON(http.StatusOK, gin.H{ - "original_url": info.StreamURL, - "stream_url": proxyURL, - "audio_url": audioProxyURL, - "title": info.Title, - "description": info.Description, - "uploader": info.Uploader, - "channel_id": info.ChannelID, - "uploader_id": info.UploaderID, - "view_count": info.ViewCount, - "thumbnail": info.Thumbnail, - "related": []interface{}{}, - "subtitle_url": nil, - "qualities": qualityOptions, - "best_quality": bestHeight, - }) -} - -func handleDownload(c *gin.Context) { - videoID := c.Query("v") - if videoID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) - return - } - - formatID := c.Query("f") - - info, err := services.GetDownloadURL(videoID, formatID) - if err != nil { - log.Printf("GetDownloadURL Error: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"}) - return - } - - c.JSON(http.StatusOK, info) -} - -func handleDownloadFile(c *gin.Context) { - videoID := c.Query("v") - if videoID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) - return - } - - formatID := c.Query("f") - - // Get the download URL from yt-dlp - info, err := services.GetDownloadURL(videoID, formatID) - if err != nil { - log.Printf("GetDownloadURL Error: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"}) - return - } - - if info.URL == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "No download URL available"}) - return - } - - // Create request to the video URL - req, err := http.NewRequest("GET", info.URL, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) - return - } - - // Copy range header if present (for partial content/resumable downloads) - if rangeHeader := c.GetHeader("Range"); rangeHeader != "" { - req.Header.Set("Range", rangeHeader) - } - - // Set appropriate headers for YouTube - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") - req.Header.Set("Referer", "https://www.youtube.com/") - req.Header.Set("Origin", "https://www.youtube.com") - - // Make the request - resp, err := httpClient.Do(req) - if err != nil { - log.Printf("Failed to fetch video: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video"}) - return - } - defer resp.Body.Close() - - // Copy relevant headers from YouTube response to our response - for key, values := range resp.Header { - if key == "Content-Type" || key == "Content-Length" || key == "Content-Range" || - key == "Accept-Ranges" || key == "Content-Disposition" { - for _, value := range values { - c.Header(key, value) - } - } - } - - // Set content type based on extension - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - if info.Ext == "mp4" { - contentType = "video/mp4" - } else if info.Ext == "webm" { - contentType = "video/webm" - } else { - contentType = "application/octet-stream" - } - } - c.Header("Content-Type", contentType) - - // Set content disposition for download - filename := fmt.Sprintf("%s.%s", info.Title, info.Ext) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - - // Copy status code - c.Status(resp.StatusCode) - - // Stream the video - io.Copy(c.Writer, resp.Body) -} - -func handleGetFormats(c *gin.Context) { - videoID := c.Query("v") - if videoID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) - return - } - - formats, err := services.GetVideoFormats(videoID) - if err != nil { - log.Printf("GetVideoFormats Error: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video formats"}) - return - } - - c.JSON(http.StatusOK, formats) + c.JSON(http.StatusOK, video) } +// Get video qualities func handleGetQualities(c *gin.Context) { - videoID := c.Query("v") + videoID := c.Param("id") if videoID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"}) return } qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID) if err != nil { - log.Printf("GetVideoQualities Error: %v", err) + log.Printf("GetQualities error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"}) return } - var result []gin.H - for _, q := range qualities { - proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL) - audioProxyURL := "" - if q.AudioURL != "" { - audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL) - } - result = append(result, gin.H{ - "format_id": q.FormatID, - "label": q.Label, - "resolution": q.Resolution, - "height": q.Height, - "url": proxyURL, - "audio_url": audioProxyURL, - "is_hls": q.IsHLS, - "vcodec": q.VCodec, - "acodec": q.ACodec, - "filesize": q.Filesize, - "has_audio": q.HasAudio, - }) - } - - // Also return the best audio URL separately - audioProxyURL := "" - if audioURL != "" { - audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL) - } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "qualities": result, - "audio_url": audioProxyURL, - }) -} - -func handleGetStreamByQuality(c *gin.Context) { - videoID := c.Query("v") - if videoID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) - return - } - - heightStr := c.Query("q") - height := 0 - if heightStr != "" { - if parsed, err := strconv.Atoi(heightStr); err == nil { - height = parsed - } - } - - qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID) - if err != nil { - log.Printf("GetVideoQualities Error: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"}) - return - } - - if len(qualities) == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "No qualities available"}) - return - } - - var selected *services.QualityFormat - for i := range qualities { - if qualities[i].Height == height { - selected = &qualities[i] - break - } - } - - if selected == nil { - selected = &qualities[0] - } - - proxyURL := "/video_proxy?url=" + url.QueryEscape(selected.URL) - - audioProxyURL := "" - if selected.AudioURL != "" { - audioProxyURL = "/video_proxy?url=" + url.QueryEscape(selected.AudioURL) - } else if audioURL != "" { - audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL) - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "stream_url": proxyURL, - "audio_url": audioProxyURL, - "has_audio": selected.HasAudio, - "quality": gin.H{ - "label": selected.Label, - "height": selected.Height, - "is_hls": selected.IsHLS, - }, + "qualities": qualities, + "audio_url": audioURL, }) } +// Get related videos func handleRelatedVideos(c *gin.Context) { - videoID := c.Query("v") - title := c.Query("title") - uploader := c.Query("uploader") - - if title == "" && videoID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID or Title required"}) + videoID := c.Param("id") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"}) return } limitStr := c.Query("limit") - limit := 10 - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit := 15 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 { limit = l } - videos, err := services.GetRelatedVideos(title, uploader, limit) + // First get video info to get title and uploader + video, err := services.GetVideoInfo(videoID) if err != nil { - log.Printf("GetRelatedVideos Error: %v", err) + log.Printf("GetVideoInfo for related error: %v", err) + // Fallback: search for similar content + results, err := services.SearchVideos("music", limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"}) + return + } + c.JSON(http.StatusOK, results) + return + } + + related, err := services.GetRelatedVideos(video.Title, video.Uploader, limit) + if err != nil { + log.Printf("GetRelatedVideos error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"}) return } - c.JSON(http.StatusOK, videos) -} - -func handleTranscript(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Not Implemented"}) + c.JSON(http.StatusOK, related) } +// Get video comments func handleComments(c *gin.Context) { - videoID := c.Query("v") + videoID := c.Param("id") if videoID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"}) return } + limitStr := c.Query("limit") limit := 20 - if l := c.Query("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { - limit = parsed - } + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l } comments, err := services.GetComments(videoID, limit) if err != nil { - log.Printf("GetComments Error: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get comments"}) + log.Printf("GetComments error: %v", err) + c.JSON(http.StatusOK, []interface{}{}) // Return empty array instead of error return } c.JSON(http.StatusOK, comments) } -func handleChannelInfo(c *gin.Context) { - channelID := c.Query("id") - if channelID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"}) +// Get download URL +func handleDownload(c *gin.Context) { + videoID := c.Param("id") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"}) return } - info, err := services.GetChannelInfo(channelID) + formatID := c.Query("format") + + downloadInfo, err := services.GetDownloadURL(videoID, formatID) if err != nil { - log.Printf("GetChannelInfo Error: %v", err) + log.Printf("GetDownloadURL error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"}) + return + } + + c.JSON(http.StatusOK, downloadInfo) +} + +// Get channel info +func handleChannelInfo(c *gin.Context) { + channelID := c.Query("id") + if channelID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"}) + return + } + + channelInfo, err := services.GetChannelInfo(channelID) + if err != nil { + log.Printf("GetChannelInfo error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel info"}) return } - c.JSON(http.StatusOK, info) + c.JSON(http.StatusOK, channelInfo) } +// Get channel videos func handleChannelVideos(c *gin.Context) { channelID := c.Query("id") if channelID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"}) return } limitStr := c.Query("limit") limit := 30 - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } videos, err := services.GetChannelVideos(channelID, limit) if err != nil { - log.Printf("GetChannelVideos Error: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos", "details": err.Error()}) + log.Printf("GetChannelVideos error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos"}) return } c.JSON(http.StatusOK, videos) } -func handleVideoProxy(c *gin.Context) { - targetURL := c.Query("url") - if targetURL == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "No URL provided"}) - return - } - - // SSRF Protection: Validate target domain - if err := isAllowedDomain(targetURL); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "URL domain not allowed"}) - return - } - - req, err := http.NewRequest("GET", targetURL, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) - return - } - - // Forward standard headers - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") - req.Header.Set("Referer", "https://www.youtube.com/") - req.Header.Set("Origin", "https://www.youtube.com") - - if rangeHeader := c.GetHeader("Range"); rangeHeader != "" { - req.Header.Set("Range", rangeHeader) - } - - resp, err := httpClient.Do(req) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"}) - return - } - defer resp.Body.Close() - - contentType := resp.Header.Get("Content-Type") - baseURL := targetURL[:strings.LastIndex(targetURL, "/")] - - isManifest := strings.Contains(strings.ToLower(contentType), "mpegurl") || - strings.HasSuffix(targetURL, ".m3u8") || - strings.Contains(targetURL, ".m3u8") - - if isManifest && (resp.StatusCode == 200 || resp.StatusCode == 206) { - // Rewrite M3U8 Manifest - scanner := bufio.NewScanner(resp.Body) - var newLines []string - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line != "" && !strings.HasPrefix(line, "#") { - fullURL := line - if !strings.HasPrefix(line, "http") { - fullURL = baseURL + "/" + line - } - encodedURL := url.QueryEscape(fullURL) - newLines = append(newLines, "/video_proxy?url="+encodedURL) - } else { - newLines = append(newLines, line) - } - } - - rewrittenContent := strings.Join(newLines, "\n") - c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewrittenContent)) - return - } - - // Stream binary video data - for k, v := range resp.Header { - logKey := strings.ToLower(k) - if logKey != "content-encoding" && logKey != "transfer-encoding" && logKey != "connection" && !strings.HasPrefix(logKey, "access-control-") { - c.Writer.Header()[k] = v - } - } - c.Writer.WriteHeader(resp.StatusCode) - io.Copy(c.Writer, resp.Body) -} - +// History handlers func handlePostHistory(c *gin.Context) { var body struct { VideoID string `json:"video_id"` @@ -707,14 +343,13 @@ func handleGetHistory(c *gin.Context) { } // Make the API response shape match the VideoData shape the frontend expects - // We'll reconstruct a basic VideoData-like array for the frontend var results []services.VideoData for _, h := range history { results = append(results, services.VideoData{ ID: h.ID, Title: h.Title, Thumbnail: h.Thumbnail, - Uploader: "History", // Just a placeholder + Uploader: "History", }) } @@ -737,6 +372,7 @@ func handleGetSuggestions(c *gin.Context) { c.JSON(http.StatusOK, suggestions) } +// Subscription handlers func handleSubscribe(c *gin.Context) { var body struct { ChannelID string `json:"channel_id"` @@ -804,3 +440,7 @@ func handleGetSubscriptions(c *gin.Context) { c.JSON(http.StatusOK, subs) } + +func logPrintf(format string, v ...interface{}) { + log.Printf(format, v...) +} diff --git a/backend/services/history.go b/backend/services/history.go index 8e9b290..a32ae02 100644 --- a/backend/services/history.go +++ b/backend/services/history.go @@ -2,7 +2,6 @@ package services import ( "log" - "strings" "kvtube-go/models" ) @@ -67,30 +66,10 @@ func GetHistory(limit int) ([]HistoryVideo, error) { } // GetSuggestions retrieves suggestions based on the user's recent history +// NOTE: This function now returns empty results since we're using client-side YouTube API +// The frontend should use the YouTube API directly for suggestions func GetSuggestions(limit int) ([]VideoData, error) { - // 1. Get the 3 most recently watched videos to extract keywords - history, err := GetHistory(3) - if err != nil || len(history) == 0 { - // Fallback to trending if no history - return SearchVideos("trending videos", limit) - } - - // 2. Build a combined query string from titles - var words []string - for _, h := range history { - // take first few words from title - parts := strings.Fields(h.Title) - for i := 0; i < len(parts) && i < 3; i++ { - // clean up some common punctuation if needed, or just let yt-dlp handle it - words = append(words, parts[i]) - } - } - - query := strings.Join(words, " ") - if query == "" { - query = "popular videos" - } - - // 3. Search using yt-dlp - return SearchVideos(query, limit) + // Return empty results - suggestions are now handled client-side + // Frontend should use YouTube API for suggestions + return []VideoData{}, nil } diff --git a/backend/services/ytdlp.go b/backend/services/ytdlp.go index 14c6ef1..9e310d7 100644 --- a/backend/services/ytdlp.go +++ b/backend/services/ytdlp.go @@ -107,7 +107,8 @@ func sanitizeVideoData(entry YtDlpEntry) VideoData { thumbnail := "" if entry.ID != "" { - thumbnail = fmt.Sprintf("https://i.ytimg.com/vi/%s/maxresdefault.jpg", entry.ID) + // Use hqdefault.jpg which is more reliably available than maxresdefault.jpg + thumbnail = fmt.Sprintf("https://i.ytimg.com/vi/%s/hqdefault.jpg", entry.ID) } return VideoData{ @@ -251,15 +252,23 @@ func GetVideoInfo(videoID string) (*VideoData, error) { url, } - cacheKey := "video_info:" + videoID - out, err := RunYtDlpCached(cacheKey, 3600, args...) // Cache for 1 hour + // Skip cache for now to avoid corrupted data issues + out, err := RunYtDlp(args...) if err != nil { + log.Printf("yt-dlp failed for %s: %v", videoID, err) return nil, err } + // Log first 500 chars for debugging + if len(out) > 0 { + log.Printf("yt-dlp response for %s (first 200 chars): %s", videoID, string(out[:min(200, len(out))])) + } + var entry YtDlpEntry if err := json.Unmarshal(out, &entry); err != nil { - return nil, err + log.Printf("JSON unmarshal error for %s: %v", videoID, err) + log.Printf("Raw response: %s", string(out[:min(500, len(out))])) + return nil, fmt.Errorf("failed to parse video info: %w", err) } data := sanitizeVideoData(entry) @@ -268,6 +277,13 @@ func GetVideoInfo(videoID string) (*VideoData, error) { return &data, nil } +func min(a, b int) int { + if a < b { + return a + } + return b +} + type QualityFormat struct { FormatID string `json:"format_id"` Label string `json:"label"` diff --git a/fix_urls.js b/fix_urls.js deleted file mode 100644 index e2df077..0000000 --- a/fix_urls.js +++ /dev/null @@ -1,33 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const glob = require('glob'); - -// Use simple find approach for ts/tsx files -const execSync = require('child_process').execSync; -const files = execSync('find frontend -type f -name "*.ts" -o -name "*.tsx"').toString().trim().split('\n'); - -files.forEach(file => { - let content = fs.readFileSync(file, 'utf8'); - // Revert the bad perl replacement - content = content.replace(/\$\{process\.env\.NEXT_PUBLIC_API_URL \|\| 'http:\/\/127\.0\.0\.1:8080'\}/g, 'http://127.0.0.1:8080'); - - // Apply proper replacement: - // const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; - // fetch(`http://127.0.0.1:8080...`) -> fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}...`) - - // Actually, only fetch calls need the environment var, but we can just replace 'http://127.0.0.1:8080' - // with API_BASE if we import or define API_BASE. Since it's easier, let's just replace the raw literal string. - - content = content.replace(/'http:\/\/127\.0\.0\.1:8080/g, '`${process.env.NEXT_PUBLIC_API_URL || \'http://127.0.0.1:8080\'}'); - content = content.replace(/"http:\/\/127\.0\.0\.1:8080/g, '`${process.env.NEXT_PUBLIC_API_URL || \'http://127.0.0.1:8080\'}'); - content = content.replace(/`http:\/\/127\.0\.0\.1:8080/g, '`${process.env.NEXT_PUBLIC_API_URL || \'http://127.0.0.1:8080\'}'); - - // We have to be careful not to double replace. The above will turn 'http://127.0.0.1:8080/api...' into - // `${process.env...}/api...` - - // Let's actually just revert the breaking perl replace first: - // It looks like: process.env.NEXT_PUBLIC_API_URL || '${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}' - - fs.writeFileSync(file, content); -}); -console.log("Done"); diff --git a/frontend/app/ClientHomePage.tsx b/frontend/app/ClientHomePage.tsx new file mode 100644 index 0000000..a8e2f22 --- /dev/null +++ b/frontend/app/ClientHomePage.tsx @@ -0,0 +1,621 @@ +'use client'; + +import { useEffect, useState, useRef, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { searchVideosClient, getTrendingVideosClient } from './clientActions'; +import { VideoData } from './constants'; +import LoadingSpinner from './components/LoadingSpinner'; + +// Format view count +function formatViews(views: number): string { + if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M views'; + if (views >= 1000) return (views / 1000).toFixed(0) + 'K views'; + return views === 0 ? '' : `${views} views`; +} + +// Get stable time ago based on video ID (deterministic, not random) +function getStableTimeAgo(videoId: string): string { + const times = ['2 hours ago', '5 hours ago', '1 day ago', '2 days ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago']; + const hash = videoId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return times[hash % times.length]; +} + +// Get fallback thumbnail URL (always works) +function getFallbackThumbnail(videoId: string): string { + return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; +} + +// Video Card Component +function VideoCard({ video }: { video: VideoData }) { + const [imgError, setImgError] = useState(false); + const [imgLoaded, setImgLoaded] = useState(false); + + // Use multiple thumbnail sources for fallback + const thumbnailSources = [ + `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`, + `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`, + `https://i.ytimg.com/vi/${video.id}/sddefault.jpg`, + `https://i.ytimg.com/vi/${video.id}/default.jpg`, + ]; + + const [currentSrcIndex, setCurrentSrcIndex] = useState(0); + const currentSrc = thumbnailSources[currentSrcIndex]; + + const handleError = () => { + if (currentSrcIndex < thumbnailSources.length - 1) { + setCurrentSrcIndex(prev => prev + 1); + } else { + setImgError(true); + } + }; + + return ( + +
+ {/* Thumbnail */} +
+ {!imgLoaded && !imgError && ( +
+ +
+ )} + + {!imgError ? ( + {video.title} setImgLoaded(true)} + style={{ + width: '100%', + height: '100%', + objectFit: 'cover', + display: imgLoaded ? 'block' : 'none', + transition: 'opacity 0.2s', + }} + /> + ) : ( +
+ + + +
+ )} + + {/* Duration badge */} + {video.duration && ( +
+ {video.duration} +
+ )} + + {/* Hover overlay */} +
+
+ + {/* Video Info */} +
+ {/* Title - max 2 lines */} +

+ {video.title} +

+ + {/* Channel name */} +
+ {video.uploader} +
+ + {/* Views and time */} +
+ {(video.view_count ?? 0) > 0 && {formatViews(video.view_count ?? 0)}} + {(video.view_count ?? 0) > 0 && } + {video.upload_date || video.publishedAt || getStableTimeAgo(video.id)} +
+
+
+ + ); +} + +// Category Pills Component +function CategoryPills({ + categories, + currentCategory, + onCategoryChange +}: { + categories: string[]; + currentCategory: string; + onCategoryChange: (category: string) => void; +}) { + return ( +
+ {categories.map((category) => ( + + ))} +
+ ); +} + +// Loading Skeleton +function VideoSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +// Get region from cookie +function getRegionFromCookie(): string { + if (typeof document === 'undefined') return 'VN'; + const match = document.cookie.match(/(?:^|; )region=([^;]*)/); + return match ? decodeURIComponent(match[1]) : 'VN'; +} + +// Check if thumbnail URL is valid (not a 404 placeholder) +function isValidThumbnail(thumbnail: string | undefined): boolean { + if (!thumbnail) return false; + // YouTube default thumbnails that are usually available + const validPatterns = [ + 'i.ytimg.com/vi/', + 'i.ytimg.com/vi_webp/', + ]; + return validPatterns.some(pattern => thumbnail.includes(pattern)); +} + +export default function ClientHomePage() { + const searchParams = useSearchParams(); + const categoryParam = searchParams.get('category') || 'All'; + const [videos, setVideos] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [currentCategory, setCurrentCategory] = useState(categoryParam); + const [page, setPage] = useState(1); + const [regionCode, setRegionCode] = useState('VN'); + const [hasMore, setHasMore] = useState(true); + + // Use refs to track state for the observer callback + const loadingMoreRef = useRef(false); + const loadingRef = useRef(true); + const hasMoreRef = useRef(true); + const pageRef = useRef(1); + + useEffect(() => { loadingMoreRef.current = loadingMore; }, [loadingMore]); + useEffect(() => { loadingRef.current = loading; }, [loading]); + useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]); + useEffect(() => { pageRef.current = page; }, [page]); + + const categories = ['All', 'Trending', 'Music', 'Gaming', 'News', 'Sports', 'Live', 'New']; + + // Region mapping for YouTube API + const REGION_MAP: Record = { + 'VN': 'Vietnam', + 'US': 'United States', + 'JP': 'Japan', + 'KR': 'South Korea', + 'IN': 'India', + 'GB': 'United Kingdom', + 'GLOBAL': '', + }; + + // Initialize region from cookie + useEffect(() => { + const region = getRegionFromCookie(); + setRegionCode(region); + }, []); + + // Load videos when category or region changes + useEffect(() => { + loadVideos(currentCategory, 1); + }, [currentCategory, regionCode]); + + // Listen for region changes + useEffect(() => { + const checkRegionChange = () => { + const newRegion = getRegionFromCookie(); + setRegionCode(prev => { + if (newRegion !== prev) { + return newRegion; + } + return prev; + }); + }; + + // Listen for custom event from RegionSelector + const handleRegionChange = (e: CustomEvent) => { + if (e.detail?.region) { + setRegionCode(e.detail.region); + } + }; + + // Check when tab becomes visible + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + checkRegionChange(); + } + }; + + // Check when window gets focus + const handleFocus = () => { + checkRegionChange(); + }; + + window.addEventListener('regionchange', handleRegionChange as EventListener); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleFocus); + + // Also poll every 3 seconds as backup + const interval = setInterval(checkRegionChange, 3000); + + return () => { + window.removeEventListener('regionchange', handleRegionChange as EventListener); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleFocus); + clearInterval(interval); + }; + }, []); // Run once on mount + + const loadVideos = async (category: string, pageNum: number) => { + try { + setLoading(true); + let results: VideoData[] = []; + const regionLabel = REGION_MAP[regionCode] || ''; + const regionSuffix = regionLabel ? ` ${regionLabel}` : ''; + + // All categories use region-specific search + if (category === 'Trending') { + results = await getTrendingVideosClient(regionCode, 30); + } else if (category === 'All') { + // Use region-specific trending for "All" + results = await getTrendingVideosClient(regionCode, 30); + } else { + // Category-specific search with region + const query = `${category}${regionSuffix}`; + results = await searchVideosClient(query, 30); + } + + // Remove duplicates and filter out videos without thumbnails + const uniqueResults = results.filter((video, index, self) => { + const isUnique = index === self.findIndex(v => v.id === video.id); + const hasThumbnail = isValidThumbnail(video.thumbnail); + return isUnique && hasThumbnail; + }); + + setVideos(uniqueResults); + setPage(pageNum); + setHasMore(true); + hasMoreRef.current = true; + } catch (error) { + console.error('Failed to load videos:', error); + } finally { + setLoading(false); + } + }; + + const handleCategoryChange = (category: string) => { + setCurrentCategory(category); + const url = new URL(window.location.href); + url.searchParams.set('category', category); + window.history.pushState({}, '', url); + }; + + const loadMore = useCallback(async () => { + if (loadingMoreRef.current || loadingRef.current || !hasMoreRef.current) return; + + setLoadingMore(true); + const nextPage = pageRef.current + 1; + + try { + const regionLabel = REGION_MAP[regionCode] || ''; + const regionSuffix = regionLabel ? ` ${regionLabel}` : ''; + + // Generate varied search queries - ALL include region + const searchVariations = [ + `trending${regionSuffix}`, + `popular videos${regionSuffix}`, + `viral 2026${regionSuffix}`, + `music${regionSuffix}`, + `entertainment${regionSuffix}`, + `gaming${regionSuffix}`, + `funny${regionSuffix}`, + `news${regionSuffix}`, + `sports${regionSuffix}`, + `new videos${regionSuffix}`, + ]; + + const queryIndex = (nextPage - 1) % searchVariations.length; + const searchQuery = searchVariations[queryIndex]; + + // Always use search for variety - trending API returns same results + const moreVideos = await searchVideosClient(searchQuery, 30); + + // Remove duplicates and filter out videos without thumbnails + setVideos(prev => { + const existingIds = new Set(prev.map(v => v.id)); + const uniqueNewVideos = moreVideos.filter(v => + !existingIds.has(v.id) && isValidThumbnail(v.thumbnail) + ); + + // If no new videos after filtering, stop infinite scroll + if (uniqueNewVideos.length < 3) { + setHasMore(false); + hasMoreRef.current = false; + } + + return [...prev, ...uniqueNewVideos]; + }); + + setPage(nextPage); + } catch (error) { + console.error('Failed to load more videos:', error); + // Don't stop infinite scroll on error - allow retry on next scroll + } finally { + setLoadingMore(false); + } + }, [currentCategory, regionCode]); + + // Ref for the loadMore function to avoid stale closures + const loadMoreCallbackRef = useRef(loadMore); + useEffect(() => { + loadMoreCallbackRef.current = loadMore; + }, [loadMore]); + + // Infinite scroll using Intersection Observer + useEffect(() => { + // Don't set up observer while loading or if no videos + if (loading || videos.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry.isIntersecting && !loadingMoreRef.current && !loadingRef.current && hasMoreRef.current) { + console.log('Sentinel intersecting, loading more...'); + loadMoreCallbackRef.current(); + } + }, + { + rootMargin: '600px', + threshold: 0 + } + ); + + // Small delay to ensure DOM is ready + const timer = setTimeout(() => { + const sentinel = document.getElementById('scroll-sentinel'); + console.log('Sentinel element:', sentinel); + if (sentinel) { + observer.observe(sentinel); + } + }, 50); + + return () => { + clearTimeout(timer); + observer.disconnect(); + }; + }, [loading, videos.length]); // Re-run when loading finishes or videos change + + return ( +
+
+ {/* Category Pills */} + + + {/* Video Grid */} + {loading ? ( +
+ {[...Array(12)].map((_, i) => ( + + ))} +
+ ) : ( + <> +
+ {videos.map((video) => ( + + ))} +
+ + {/* Scroll Sentinel for Infinite Scroll */} +
+ + {/* Loading More Indicator */} + {loadingMore && ( +
+ +
+ )} + + {/* End of Results */} + {!hasMore && videos.length > 0 && ( +
+ You've reached the end +
+ )} + + {/* Empty State */} + {videos.length === 0 && !loading && ( +
+ + + +

No videos found

+

Try selecting a different category

+
+ )} + + )} +
+ + {/* Animations */} + +
+ ); +} \ No newline at end of file diff --git a/frontend/app/api/download/route.ts b/frontend/app/api/download/route.ts deleted file mode 100644 index 885284e..0000000 --- a/frontend/app/api/download/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; - -export async function GET(request: NextRequest) { - const videoId = request.nextUrl.searchParams.get('v'); - const formatId = request.nextUrl.searchParams.get('f'); - - if (!videoId) { - return NextResponse.json({ error: 'No video ID' }, { status: 400 }); - } - - try { - const url = `${API_BASE}/api/download-file?v=${encodeURIComponent(videoId)}${formatId ? `&f=${encodeURIComponent(formatId)}` : ''}`; - const res = await fetch(url, { - cache: 'no-store', - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - return NextResponse.json({ error: data.error || 'Download failed' }, { status: res.status }); - } - - // Stream the file directly - const headers = new Headers(); - const contentType = res.headers.get('content-type'); - const contentDisposition = res.headers.get('content-disposition'); - - if (contentType) headers.set('content-type', contentType); - if (contentDisposition) headers.set('content-disposition', contentDisposition); - - return new NextResponse(res.body, { - status: res.status, - headers, - }); - } catch (error) { - return NextResponse.json({ error: 'Failed to get download link' }, { status: 500 }); - } -} diff --git a/frontend/app/api/formats/route.ts b/frontend/app/api/formats/route.ts deleted file mode 100644 index 988679b..0000000 --- a/frontend/app/api/formats/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; - -export async function GET(request: NextRequest) { - const videoId = request.nextUrl.searchParams.get('v'); - - if (!videoId) { - return NextResponse.json({ error: 'No video ID' }, { status: 400 }); - } - - try { - const res = await fetch(`${API_BASE}/api/formats?v=${encodeURIComponent(videoId)}`, { - cache: 'no-store', - }); - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to fetch formats' }, { status: 500 }); - } - - const data = await res.json(); - return NextResponse.json(data); - } catch (error) { - return NextResponse.json({ error: 'Failed to fetch formats' }, { status: 500 }); - } -} diff --git a/frontend/app/api/history/route.ts b/frontend/app/api/history/route.ts deleted file mode 100644 index 6e5dfd5..0000000 --- a/frontend/app/api/history/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { video_id, title, thumbnail } = body; - - if (!video_id) { - return NextResponse.json({ error: 'No video ID' }, { status: 400 }); - } - - const res = await fetch(`${API_BASE}/api/history`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - video_id, - title: title || `Video ${video_id}`, - thumbnail: thumbnail || `https://i.ytimg.com/vi/${video_id}/maxresdefault.jpg`, - }), - cache: 'no-store', - }); - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to record history' }, { status: res.status }); - } - - const data = await res.json(); - return NextResponse.json({ success: true, ...data }); - } catch (error) { - console.error('API /api/history POST error:', error); - return NextResponse.json({ error: 'Failed to record history' }, { status: 500 }); - } -} diff --git a/frontend/app/api/proxy-file/route.ts b/frontend/app/api/proxy-file/route.ts deleted file mode 100644 index 5dd22af..0000000 --- a/frontend/app/api/proxy-file/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -export async function GET(request: NextRequest) { - const fileUrl = request.nextUrl.searchParams.get('url'); - - if (!fileUrl) { - return NextResponse.json({ error: 'No URL provided' }, { status: 400 }); - } - - try { - const res = await fetch(fileUrl, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://www.youtube.com/', - 'Origin': 'https://www.youtube.com', - }, - }); - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to fetch file' }, { status: res.status }); - } - - const contentType = res.headers.get('content-type') || 'application/octet-stream'; - const contentLength = res.headers.get('content-length'); - - const headers: HeadersInit = { - 'Content-Type': contentType, - 'Access-Control-Allow-Origin': '*', - }; - - if (contentLength) { - headers['Content-Length'] = contentLength; - } - - return new NextResponse(res.body, { - status: 200, - headers, - }); - } catch (error) { - return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 }); - } -} diff --git a/frontend/app/api/proxy-stream/route.ts b/frontend/app/api/proxy-stream/route.ts deleted file mode 100644 index f9347f7..0000000 --- a/frontend/app/api/proxy-stream/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -export async function GET(request: NextRequest) { - const url = request.nextUrl.searchParams.get('url'); - - if (!url) { - return NextResponse.json({ error: 'No URL provided' }, { status: 400 }); - } - - try { - const res = await fetch(decodeURIComponent(url), { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://www.youtube.com/', - }, - }); - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 }); - } - - const contentType = res.headers.get('content-type') || 'video/mp4'; - const contentLength = res.headers.get('content-length'); - - const headers = new Headers({ - 'Content-Type': contentType, - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'public, max-age=3600', - }); - - if (contentLength) { - headers.set('Content-Length', contentLength); - } - - return new NextResponse(res.body, { - status: 200, - headers, - }); - } catch (error) { - return NextResponse.json({ error: 'Proxy failed' }, { status: 500 }); - } -} diff --git a/frontend/app/api/stream/route.ts b/frontend/app/api/stream/route.ts deleted file mode 100644 index d3c60df..0000000 --- a/frontend/app/api/stream/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -export async function GET(request: NextRequest) { - const videoId = request.nextUrl.searchParams.get('v'); - - if (!videoId) { - return NextResponse.json({ error: 'No video ID' }, { status: 400 }); - } - - try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/get_stream_info?v=${videoId}`, { - cache: 'no-store', - }); - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 }); - } - - const data = await res.json(); - const streamUrl = data.original_url || data.stream_url; - const proxyUrl = streamUrl ? `/api/proxy-stream?url=${encodeURIComponent(streamUrl)}` : null; - - return NextResponse.json({ - streamUrl: proxyUrl, - title: data.title, - thumbnail: data.thumbnail - }); - } catch (error) { - return NextResponse.json({ error: 'Failed to fetch stream' }, { status: 500 }); - } -} diff --git a/frontend/app/api/subscribe/route.ts b/frontend/app/api/subscribe/route.ts deleted file mode 100644 index 3115ca5..0000000 --- a/frontend/app/api/subscribe/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; - -export async function GET(request: NextRequest) { - const channelId = request.nextUrl.searchParams.get('channel_id'); - - if (!channelId) { - return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); - } - - try { - const res = await fetch(`${API_BASE}/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, { - cache: 'no-store', - }); - - if (!res.ok) { - return NextResponse.json({ subscribed: false }); - } - - const data = await res.json(); - return NextResponse.json({ subscribed: data.subscribed || false }); - } catch (error) { - return NextResponse.json({ subscribed: false }); - } -} - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { channel_id, channel_name, channel_avatar } = body; - - if (!channel_id) { - return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); - } - - const res = await fetch(`${API_BASE}/api/subscribe`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - channel_id, - channel_name: channel_name || channel_id, - channel_avatar: channel_avatar || '?', - }), - cache: 'no-store', - }); - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 }); - } - - const data = await res.json(); - return NextResponse.json({ success: true, ...data }); - } catch (error) { - return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 }); - } -} - -export async function DELETE(request: NextRequest) { - const channelId = request.nextUrl.searchParams.get('channel_id'); - - if (!channelId) { - return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); - } - - try { - const res = await fetch(`${API_BASE}/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, { - method: 'DELETE', - cache: 'no-store', - }); - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 }); - } - - return NextResponse.json({ success: true }); - } catch (error) { - return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 }); - } -} diff --git a/frontend/app/channel/[id]/page.tsx b/frontend/app/channel/[id]/page.tsx index 3683f9b..b9a83f6 100644 --- a/frontend/app/channel/[id]/page.tsx +++ b/frontend/app/channel/[id]/page.tsx @@ -27,9 +27,11 @@ function formatSubscribers(count: number): string { // We no longer need getAvatarColor as we now use the global --yt-avatar-bg +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api'; + async function getChannelInfo(id: string) { try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/info?id=${id}`, { cache: 'no-store' }); + const res = await fetch(`${API_BASE}/channel/info?id=${id}`, { cache: 'no-store' }); if (!res.ok) return null; return res.json() as Promise; } catch (e) { @@ -40,7 +42,7 @@ async function getChannelInfo(id: string) { async function getChannelVideos(id: string) { try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/videos?id=${id}&limit=30`, { cache: 'no-store' }); + const res = await fetch(`${API_BASE}/channel/videos?id=${id}&limit=30`, { cache: 'no-store' }); if (!res.ok) return []; return res.json() as Promise; } catch (e) { diff --git a/frontend/app/clientActions.ts b/frontend/app/clientActions.ts new file mode 100644 index 0000000..13d4a4a --- /dev/null +++ b/frontend/app/clientActions.ts @@ -0,0 +1,271 @@ +'use client'; + +import { VideoData } from './constants'; + +// Backend API base URL +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api'; + +// Transform backend response to our VideoData format +function transformVideo(item: any): VideoData { + return { + id: item.id || '', + title: item.title || 'Untitled', + thumbnail: item.thumbnail || `https://i.ytimg.com/vi/${item.id}/hqdefault.jpg`, + channelTitle: item.uploader || item.channelTitle || 'Unknown', + channelId: item.channel_id || item.channelId || '', + viewCount: formatViews(item.view_count || 0), + publishedAt: formatRelativeTime(item.upload_date || item.uploaded), + duration: item.duration || '', + description: item.description || '', + uploader: item.uploader, + uploader_id: item.uploader_id, + channel_id: item.channel_id, + view_count: item.view_count || 0, + upload_date: item.upload_date, + }; +} + +function formatViews(views: number): string { + if (!views) return '0'; + if (views >= 1000000000) return (views / 1000000000).toFixed(1) + 'B'; + if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M'; + if (views >= 1000) return (views / 1000).toFixed(1) + 'K'; + return views.toString(); +} + +function formatRelativeTime(input: any): string { + if (!input) return 'recently'; + if (typeof input === 'string' && input.includes('ago')) return input; + + const date = new Date(input); + if (isNaN(date.getTime())) return 'recently'; + + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 7) return `${days} days ago`; + if (days < 30) return `${Math.floor(days / 7)} weeks ago`; + if (days < 365) return `${Math.floor(days / 30)} months ago`; + return `${Math.floor(days / 365)} years ago`; +} + +// Search videos using backend API +export async function searchVideosClient(query: string, limit: number = 20): Promise { + try { + const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(query)}&limit=${limit}`, { + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + if (!Array.isArray(data)) return []; + + return data.map(transformVideo).filter((v: VideoData) => v.id && v.title); + } catch (error) { + console.error('Search failed:', error); + return []; + } +} + +// Get video details using backend API +export async function getVideoDetailsClient(videoId: string): Promise { + try { + const response = await fetch(`${API_BASE}/video/${videoId}`, { + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + return transformVideo(data); + } catch (error) { + console.error('Get video details failed:', error); + return null; + } +} + +// Get related videos using backend API +export async function getRelatedVideosClient(videoId: string, limit: number = 15): Promise { + try { + const response = await fetch(`${API_BASE}/video/${videoId}/related?limit=${limit}`, { + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + if (!Array.isArray(data)) return []; + + return data.map(transformVideo).filter((v: VideoData) => v.id && v.title).slice(0, limit); + } catch (error) { + console.error('Get related videos failed:', error); + return []; + } +} + +// Get trending videos using backend API with region support +export async function getTrendingVideosClient(regionCode: string = 'US', limit: number = 20): Promise { + // Map region codes to search queries for region-specific trending + const regionNames: Record = { + 'VN': 'Vietnam', + 'US': 'United States', + 'JP': 'Japan', + 'KR': 'South Korea', + 'IN': 'India', + 'GB': 'United Kingdom', + 'DE': 'Germany', + 'FR': 'France', + 'BR': 'Brazil', + 'MX': 'Mexico', + 'CA': 'Canada', + 'AU': 'Australia', + 'GLOBAL': '', + }; + + const regionName = regionNames[regionCode] || ''; + const searchQuery = regionName + ? `trending ${regionName} 2026` + : 'trending videos 2026'; + + try { + const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(searchQuery)}&limit=${limit}`, { + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + if (!Array.isArray(data)) return []; + + return data.map(transformVideo).filter((v: VideoData) => v.id && v.title).slice(0, limit); + } catch (error) { + console.error('Get trending videos failed:', error); + return []; + } +} + +// Get comments using backend API +export async function getCommentsClient(videoId: string, limit: number = 20): Promise { + try { + const response = await fetch(`${API_BASE}/video/${videoId}/comments?limit=${limit}`, { + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + if (!Array.isArray(data)) return []; + + return data.map((c: any) => ({ + id: c.id, + text: c.text || c.content, + author: c.author, + authorId: c.author_id, + authorThumbnail: c.author_thumbnail, + likes: c.likes || 0, + published: c.timestamp || 'recently', + isReply: c.is_reply || false, + })); + } catch (error) { + console.error('Get comments failed:', error); + return []; + } +} + +// Get channel info using backend API +export async function getChannelInfoClient(channelId: string): Promise { + try { + const response = await fetch(`${API_BASE}/channel/info?id=${channelId}`, { + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + return { + id: data.id || channelId, + title: data.title || 'Unknown Channel', + avatar: data.avatar || '', + banner: data.banner || '', + subscriberCount: data.subscriber_count || 0, + description: data.description || '', + }; + } catch (error) { + console.error('Get channel info failed:', error); + return null; + } +} + +// Get channel videos using backend API +export async function getChannelVideosClient(channelId: string, limit: number = 30): Promise { + try { + const response = await fetch(`${API_BASE}/channel/videos?id=${channelId}&limit=${limit}`, { + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + if (!Array.isArray(data)) return []; + + return data.map(transformVideo).filter((v: VideoData) => v.id && v.title); + } catch (error) { + console.error('Get channel videos failed:', error); + return []; + } +} + +// Fetch more videos for pagination +export async function fetchMoreVideosClient( + currentCategory: string, + regionLabel: string, + page: number, + contextVideoId?: string +): Promise { + const modifiers = ['', 'more', 'new', 'update', 'latest', 'part 2']; + const modifier = page < modifiers.length ? modifiers[page] : `page ${page}`; + + let searchQuery = ''; + + switch (currentCategory) { + case 'All': + case 'Trending': + searchQuery = `trending ${modifier}`; + break; + case 'Music': + searchQuery = `music ${modifier}`; + break; + case 'Gaming': + searchQuery = `gaming ${modifier}`; + break; + case 'News': + searchQuery = `news ${modifier}`; + break; + default: + searchQuery = `${currentCategory.toLowerCase()} ${modifier}`; + } + + if (regionLabel && regionLabel !== 'Global') { + searchQuery = `${regionLabel} ${searchQuery}`; + } + + return searchVideosClient(searchQuery, 20); +} diff --git a/frontend/app/components/HamburgerMenu.tsx b/frontend/app/components/HamburgerMenu.tsx index d4f317c..4166a27 100644 --- a/frontend/app/components/HamburgerMenu.tsx +++ b/frontend/app/components/HamburgerMenu.tsx @@ -12,7 +12,7 @@ export default function HamburgerMenu() { const navItems = [ { icon: , label: 'Home', path: '/' }, - { icon: , label: 'Subscriptions', path: '/feed/subscriptions' }, + { icon: , label: 'Sub', path: '/feed/subscriptions' }, { icon: , label: 'You', path: '/feed/library' }, ]; diff --git a/frontend/app/components/InfiniteVideoGrid.tsx b/frontend/app/components/InfiniteVideoGrid.tsx index 2b0515c..a619e55 100644 --- a/frontend/app/components/InfiniteVideoGrid.tsx +++ b/frontend/app/components/InfiniteVideoGrid.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import VideoCard from './VideoCard'; import { fetchMoreVideos } from '../actions'; import { VideoData } from '../constants'; +import LoadingSpinner from './LoadingSpinner'; interface Props { initialVideos: VideoData[]; @@ -100,16 +101,7 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi {hasMore && (
- {isLoading && ( -
- )} + {isLoading && }
)} diff --git a/frontend/app/components/LoadingSpinner.tsx b/frontend/app/components/LoadingSpinner.tsx new file mode 100644 index 0000000..2bcb54b --- /dev/null +++ b/frontend/app/components/LoadingSpinner.tsx @@ -0,0 +1,75 @@ +'use client'; + +interface LoadingSpinnerProps { + size?: 'small' | 'medium' | 'large'; + fullScreen?: boolean; + text?: string; + color?: 'primary' | 'white'; +} + +const sizeMap = { + small: { spinner: 24, border: 2 }, + medium: { spinner: 36, border: 3 }, + large: { spinner: 48, border: 4 }, +}; + +export default function LoadingSpinner({ + size = 'medium', + fullScreen = false, + text, + color = 'primary' +}: LoadingSpinnerProps) { + const { spinner, border } = sizeMap[size]; + + const spinnerColor = color === 'white' ? '#fff' : 'var(--yt-text-primary)'; + const borderColor = color === 'white' ? 'rgba(255,255,255,0.2)' : 'var(--yt-border)'; + + const content = ( +
+
+ {text && ( + + {text} + + )} + +
+ ); + + if (fullScreen) { + return ( +
+ {content} +
+ ); + } + + return content; +} diff --git a/frontend/app/components/MobileNav.tsx b/frontend/app/components/MobileNav.tsx index 5ccd864..9b1376a 100644 --- a/frontend/app/components/MobileNav.tsx +++ b/frontend/app/components/MobileNav.tsx @@ -11,7 +11,7 @@ export default function MobileNav() { const navItems = [ { icon: , label: 'Home', path: '/' }, // { icon: , label: 'Shorts', path: '/shorts' }, - { icon: , label: 'Subscriptions', path: '/feed/subscriptions' }, + { icon: , label: 'Sub', path: '/feed/subscriptions' }, { icon: , label: 'You', path: '/feed/library' }, ]; diff --git a/frontend/app/components/RegionSelector.tsx b/frontend/app/components/RegionSelector.tsx index a6cb5a2..a5ef311 100644 --- a/frontend/app/components/RegionSelector.tsx +++ b/frontend/app/components/RegionSelector.tsx @@ -48,6 +48,8 @@ export default function RegionSelector() { setSelected(code); setRegionCookie(code); setIsOpen(false); + // Dispatch custom event for immediate notification + window.dispatchEvent(new CustomEvent('regionchange', { detail: { region: code } })); router.refresh(); }; diff --git a/frontend/app/components/Sidebar.tsx b/frontend/app/components/Sidebar.tsx index 02dd59a..1b8b61d 100644 --- a/frontend/app/components/Sidebar.tsx +++ b/frontend/app/components/Sidebar.tsx @@ -13,7 +13,7 @@ export default function Sidebar() { const navItems = [ { icon: , label: 'Home', path: '/' }, // { icon: , label: 'Shorts', path: '/shorts' }, - { icon: , label: 'Subscriptions', path: '/feed/subscriptions' }, + { icon: , label: 'Sub', path: '/feed/subscriptions' }, { icon: , label: 'You', path: '/feed/library' }, ]; diff --git a/frontend/app/components/VideoCard.tsx b/frontend/app/components/VideoCard.tsx index 1978581..5e76d0f 100644 --- a/frontend/app/components/VideoCard.tsx +++ b/frontend/app/components/VideoCard.tsx @@ -3,19 +3,8 @@ import Link from 'next/link'; import Image from 'next/image'; import { useState, useCallback } from 'react'; - -interface VideoData { - id: string; - title: string; - uploader: string; - channel_id?: string; - thumbnail: string; - view_count: number; - duration: string; - uploaded_date?: string; - list_id?: string; - is_mix?: boolean; -} +import { VideoData } from '@/app/constants'; +import LoadingSpinner from './LoadingSpinner'; function formatViews(views: number): string { if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M'; @@ -23,10 +12,10 @@ function formatViews(views: number): string { return views.toString(); } -function getRelativeTime(id: string): string { +function getStableRelativeTime(id: string): string { const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago']; - const index = (id.charCodeAt(0) || 0) % times.length; - return times[index]; + const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return times[hash % times.length]; } import { memo } from 'react'; @@ -34,7 +23,7 @@ import { memo } from 'react'; const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) { - const relativeTime = video.uploaded_date || getRelativeTime(video.id); + const relativeTime = video.upload_date || video.publishedAt || getStableRelativeTime(video.id); const [isNavigating, setIsNavigating] = useState(false); const destination = video.list_id ? `/watch?v=${video.id}&list=${video.list_id}` : `/watch?v=${video.id}`; const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL; @@ -89,13 +78,7 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10 }}> -
+
)} @@ -111,15 +94,15 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
{video.channel_id ? ( - {video.uploader} + {video.uploader || video.channelTitle || 'Unknown'} ) : (
- {video.uploader} + {video.uploader || video.channelTitle || 'Unknown'}
)}
- {formatViews(video.view_count)} views • {relativeTime} + {formatViews(video.view_count ?? 0)} views • {relativeTime}
diff --git a/frontend/app/constants.ts b/frontend/app/constants.ts index 8304d78..e6ec34f 100644 --- a/frontend/app/constants.ts +++ b/frontend/app/constants.ts @@ -1,12 +1,21 @@ -export const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; +export const API_BASE = ''; // No backend needed - using public APIs export interface VideoData { id: string; title: string; - uploader: string; thumbnail: string; - view_count: number; + channelTitle?: string; + channelId?: string; + viewCount?: string; + publishedAt?: string; duration: string; + description?: string; + // Legacy fields for compatibility + uploader?: string; + uploader_id?: string; + channel_id?: string; + view_count?: number; + upload_date?: string; avatar_url?: string; list_id?: string; is_mix?: boolean; diff --git a/frontend/app/feed/library/page.tsx b/frontend/app/feed/library/page.tsx index 8f52296..4f479c9 100644 --- a/frontend/app/feed/library/page.tsx +++ b/frontend/app/feed/library/page.tsx @@ -2,6 +2,8 @@ import Link from 'next/link'; import { useState, useEffect, useCallback } from 'react'; +import { getSavedVideos, type SavedVideo } from '../../storage'; +import LoadingSpinner from '../../components/LoadingSpinner'; const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; @@ -145,24 +147,94 @@ function SubscriptionCard({ subscription }: { subscription: Subscription }) { ); } +function SavedVideoCard({ video }: { video: SavedVideo }) { + const destination = `/watch?v=${video.videoId}`; + const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL; + + const handleImageError = useCallback((e: React.SyntheticEvent) => { + const img = e.target as HTMLImageElement; + if (img.src !== DEFAULT_THUMBNAIL) { + img.src = DEFAULT_THUMBNAIL; + } + }, []); + + return ( + +
+ {video.title} +
+ Saved +
+
+
+

+ {video.title} +

+

+ {video.channelTitle} +

+
+ + ); +} + export default function LibraryPage() { const [history, setHistory] = useState([]); const [subscriptions, setSubscriptions] = useState([]); + const [savedVideos, setSavedVideos] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { async function fetchData() { try { + const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api'; const [historyRes, subsRes] = await Promise.all([ - fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/history?limit=20`, { cache: 'no-store' }), - fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' }) + fetch(`${apiBase}/history?limit=20`, { cache: 'no-store' }), + fetch(`${apiBase}/subscriptions`, { cache: 'no-store' }) ]); const historyData = await historyRes.json(); const subsData = await subsRes.json(); + const savedData = getSavedVideos(20); setHistory(Array.isArray(historyData) ? historyData : []); setSubscriptions(Array.isArray(subsData) ? subsData : []); + setSavedVideos(savedData); } catch (err) { console.error('Failed to fetch library data:', err); } finally { @@ -174,15 +246,8 @@ export default function LibraryPage() { if (loading) { return ( -
-
+
+
); } @@ -192,7 +257,7 @@ export default function LibraryPage() { {subscriptions.length > 0 && (

- Subscriptions + Sub

{subscriptions.map((sub) => ( @@ -202,6 +267,23 @@ export default function LibraryPage() {
)} + {savedVideos.length > 0 && ( +
+

+ Saved Videos +

+
+ {savedVideos.map((video) => ( + + ))} +
+
+ )} +

Watch History diff --git a/frontend/app/feed/subscriptions/page.tsx b/frontend/app/feed/subscriptions/page.tsx index c5254eb..608ceb5 100644 --- a/frontend/app/feed/subscriptions/page.tsx +++ b/frontend/app/feed/subscriptions/page.tsx @@ -2,30 +2,37 @@ import Link from 'next/link'; import { useState, useEffect, useCallback } from 'react'; +import { getChannelVideosClient, getChannelInfoClient } from '../../clientActions'; +import { VideoData } from '../../constants'; +import LoadingSpinner from '../../components/LoadingSpinner'; -const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; - -interface VideoData { - id: string; - title: string; - uploader: string; - channel_id: string; - thumbnail: string; - view_count: number; - duration: string; - uploaded_date?: string; -} +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api'; interface Subscription { - id: number; channel_id: string; channel_name: string; channel_avatar: string; } +const DEFAULT_THUMBNAIL = 'data:image/svg+xml,No thumbnail'; + interface ChannelVideos { subscription: Subscription; videos: VideoData[]; + channelInfo: any; +} + +// Fetch subscriptions from backend API +async function fetchSubscriptions(): Promise { + try { + const res = await fetch(`${API_BASE}/subscriptions`, { cache: 'no-store' }); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch (e) { + console.error('Failed to fetch subscriptions:', e); + return []; + } } const INITIAL_ROWS = 2; @@ -38,12 +45,6 @@ function formatViews(views: number): string { return views.toString(); } -function getRelativeTime(id: string): string { - const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago']; - const index = (id.charCodeAt(0) || 0) % times.length; - return times[index]; -} - function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVideos: ChannelVideos; defaultExpanded?: boolean }) { const { subscription, videos } = channelVideos; const [expanded, setExpanded] = useState(defaultExpanded); @@ -85,12 +86,17 @@ function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVid fontSize: '18px', color: '#fff', fontWeight: '600', + overflow: 'hidden', }}> - {subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'} -

-

- {subscription.channel_name || subscription.channel_id} -

+ {subscription.channel_avatar ? ( + + ) : ( + subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?' + )} +
+ + {subscription.channel_name || subscription.channel_id} +
{displayedVideos.map((video) => { - const relativeTime = video.uploaded_date || getRelativeTime(video.id); + const relativeTime = video.publishedAt || video.upload_date || 'recently'; const destination = `/watch?v=${video.id}`; const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL; @@ -142,7 +148,7 @@ function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVid {video.title}

- {formatViews(video.view_count)} views • {relativeTime} + {video.viewCount || formatViews(video.view_count || 0)} views • {relativeTime}

); @@ -189,27 +195,35 @@ export default function SubscriptionsPage() { useEffect(() => { async function fetchData() { try { - const subsRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' }); - const subsData = await subsRes.json(); - const subs = Array.isArray(subsData) ? subsData : []; + const subs = await fetchSubscriptions(); const channelVideos: ChannelVideos[] = []; - for (const sub of subs) { - const videosRes = await fetch( - `${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/videos?id=${sub.channel_id}&limit=${MAX_ROWS * VIDEOS_PER_ROW}`, - { cache: 'no-store' } - ); - const videosData = await videosRes.json(); - const videos = Array.isArray(videosData) ? videosData : []; - if (videos.length > 0) { - channelVideos.push({ - subscription: sub, - videos: videos.map((v: VideoData) => ({ ...v, uploader: sub.channel_name })) - }); + + // Fetch videos for each subscription in parallel + const promises = subs.map(async (sub) => { + try { + const channelId = sub.channel_id; + const videos = await getChannelVideosClient(channelId, MAX_ROWS * VIDEOS_PER_ROW); + const channelInfo = await getChannelInfoClient(channelId); + + if (videos.length > 0) { + return { + subscription: sub, + videos: videos, + channelInfo: channelInfo || null, + }; + } + return null; + } catch (err) { + console.error(`Failed to fetch videos for ${sub.channel_id}:`, err); + return null; } - } + }); - setChannelsVideos(channelVideos); + const results = await Promise.all(promises); + const validResults = results.filter((r): r is ChannelVideos => r !== null); + + setChannelsVideos(validResults); } catch (err) { console.error('Failed to fetch subscriptions:', err); } finally { @@ -221,15 +235,8 @@ export default function SubscriptionsPage() { if (loading) { return ( -
-
+
+
); } @@ -239,13 +246,28 @@ export default function SubscriptionsPage() {

No subscriptions yet

Subscribe to channels to see their latest videos here

+ + Discover videos +
); } return (
-

Subscriptions

+

Sub

{channelsVideos.map((channelData) => ( diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 62b5b7a..712c232 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,160 +1,11 @@ -import Link from 'next/link'; -import { cookies } from 'next/headers'; -import InfiniteVideoGrid from './components/InfiniteVideoGrid'; -import VideoCard from './components/VideoCard'; -import { - getSearchVideos, - getHistoryVideos, - getSuggestedVideos, - getRelatedVideos, - getRecentHistory -} from './actions'; -import { - VideoData, - CATEGORY_MAP, - ALL_CATEGORY_SECTIONS, - addRegion, - getRandomModifier -} from './utils'; +import { Suspense } from 'react'; +import ClientHomePage from './ClientHomePage'; +import LoadingSpinner from './components/LoadingSpinner'; -export const dynamic = 'force-dynamic'; - -const REGION_LABELS: Record = { - VN: 'Vietnam', - US: 'United States', - JP: 'Japan', - KR: 'South Korea', - IN: 'India', - GB: 'United Kingdom', - GLOBAL: '', -}; - -export default async function Home({ - searchParams, -}: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }> -}) { - const awaitParams = await searchParams; - const currentCategory = (awaitParams.category as string) || 'All'; - const isAllCategory = currentCategory === 'All'; - - const cookieStore = await cookies(); - const regionCode = cookieStore.get('region')?.value || 'VN'; - const regionLabel = REGION_LABELS[regionCode] || ''; - - let gridVideos: VideoData[] = []; - const randomMod = getRandomModifier(); - - // Fetch recent history for mixing - let recentVideo: VideoData | null = null; - if (isAllCategory) { - recentVideo = await getRecentHistory(); - } - - if (isAllCategory && recentVideo) { - // 40% Suggested, 40% Related, 20% Trending = 12:12:6 for 30 items - const promises = [ - getSuggestedVideos(12), - getRelatedVideos(recentVideo.id, 12), - getSearchVideos(addRegion("trending", regionLabel) + ' ' + randomMod, 6) - ]; - - const [suggestedRes, relatedRes, trendingRes] = await Promise.all(promises); - - const interleavedList: VideoData[] = []; - const seenIds = new Set(); - - let sIdx = 0, rIdx = 0, tIdx = 0; - while (sIdx < suggestedRes.length || rIdx < relatedRes.length || tIdx < trendingRes.length) { - for (let i = 0; i < 2 && sIdx < suggestedRes.length; i++) { - const v = suggestedRes[sIdx++]; - if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); } - } - for (let i = 0; i < 2 && rIdx < relatedRes.length; i++) { - const v = relatedRes[rIdx++]; - if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); } - } - for (let i = 0; i < 1 && tIdx < trendingRes.length; i++) { - const v = trendingRes[tIdx++]; - if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); } - } - } - gridVideos = interleavedList; - - } else if (isAllCategory) { - // Fallback if no history - const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => { - return await getSearchVideos(addRegion(sec.query, regionLabel) + ' ' + randomMod, 6); - }); - - const results = await Promise.all(promises); - - // Interleave the results: 1st from Trending, 1st from Music, ... 2nd from Trending, etc. - const maxLen = Math.max(...results.map(arr => arr.length)); - const interleavedList: VideoData[] = []; - const seenIds = new Set(); - - for (let i = 0; i < maxLen; i++) { - for (const categoryResult of results) { - if (i < categoryResult.length) { - const video = categoryResult[i]; - if (!seenIds.has(video.id)) { - interleavedList.push(video); - seenIds.add(video.id); - } - } - } - } - gridVideos = interleavedList; - - } else if (currentCategory === 'Watched') { - gridVideos = await getHistoryVideos(50); - } else if (currentCategory === 'Suggested') { - gridVideos = await getSuggestedVideos(20); - } else { - const searchQuery = CATEGORY_MAP[currentCategory] || CATEGORY_MAP['All']; - gridVideos = await getSearchVideos(addRegion(searchQuery, regionLabel) + ' ' + randomMod, 30); - } - - // Remove duplicates from recent video - if (recentVideo) { - gridVideos = gridVideos.filter(video => video.id !== recentVideo!.id); - } - - const categoriesList = Object.keys(CATEGORY_MAP); - - return ( -
- {/* Category Chips Scrollbar */} -
- {categoriesList.map((cat) => { - const isActive = cat === currentCategory; - return ( - - - - ); - })} -
- -
- -
-
- ); -} +export default function Home() { + return ( + }> + + + ); +} \ No newline at end of file diff --git a/frontend/app/search/ClientSearchPage.tsx b/frontend/app/search/ClientSearchPage.tsx new file mode 100644 index 0000000..85a524d --- /dev/null +++ b/frontend/app/search/ClientSearchPage.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useEffect, useState, useRef, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { searchVideosClient } from '../clientActions'; +import { VideoData } from '../constants'; +import VideoCard from '../components/VideoCard'; +import LoadingSpinner from '../components/LoadingSpinner'; + +function SearchSkeleton() { + return ( +
+ {[...Array(12)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} + +
+ ); +} + +export default function ClientSearchPage() { + const searchParams = useSearchParams(); + const query = searchParams.get('q') || ''; + const [videos, setVideos] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [searchPage, setSearchPage] = useState(0); + const [hasMore, setHasMore] = useState(true); + const observerTarget = useRef(null); + const loadingMoreRef = useRef(false); + const hasMoreRef = useRef(true); + const searchPageRef = useRef(0); + + useEffect(() => { loadingMoreRef.current = loadingMore; }, [loadingMore]); + useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]); + useEffect(() => { searchPageRef.current = searchPage; }, [searchPage]); + + useEffect(() => { + if (query) { + performSearch(query); + } + }, [query]); + + const performSearch = async (q: string) => { + try { + setLoading(true); + setSearchPage(0); + searchPageRef.current = 0; + setHasMore(true); + hasMoreRef.current = true; + + const results = await searchVideosClient(q, 50); + const uniqueResults = results.filter((video, index, self) => + index === self.findIndex(v => v.id === video.id) + ); + setVideos(uniqueResults); + setHasMore(uniqueResults.length >= 40); + hasMoreRef.current = uniqueResults.length >= 40; + } catch (error) { + console.error('Search failed:', error); + } finally { + setLoading(false); + } + }; + + const loadMore = useCallback(async () => { + if (loadingMoreRef.current || !hasMoreRef.current || !query) return; + + setLoadingMore(true); + const nextPage = searchPageRef.current + 1; + + try { + // Use different search variations to get more results + const variations = [ + `${query}`, + `${query} official`, + `${query} video`, + `${query} review`, + `${query} tutorial`, + `${query} 2026`, + `${query} new`, + `${query} best`, + ]; + const searchVariation = variations[nextPage % variations.length]; + + const results = await searchVideosClient(searchVariation, 50); + + setVideos(prev => { + const existingIds = new Set(prev.map(v => v.id)); + const uniqueNewVideos = results.filter(v => !existingIds.has(v.id)); + + // Stop loading if we get very few new videos + if (uniqueNewVideos.length < 3) { + setHasMore(false); + hasMoreRef.current = false; + } + + return [...prev, ...uniqueNewVideos]; + }); + + setSearchPage(nextPage); + searchPageRef.current = nextPage; + } catch (error) { + console.error('Failed to load more:', error); + } finally { + setLoadingMore(false); + } + }, [query]); + + // Infinite scroll observer + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !loadingMoreRef.current && hasMoreRef.current) { + loadMore(); + } + }, + { rootMargin: '500px', threshold: 0.1 } + ); + + const timer = setTimeout(() => { + if (observerTarget.current) { + observer.observe(observerTarget.current); + } + }, 100); + + return () => { + clearTimeout(timer); + observer.disconnect(); + }; + }, [loadMore]); + + return ( +
+
+ {/* Results Header */} + {query && !loading && ( +
+ + {videos.length > 0 ? `${videos.length} results for "${query}"` : `No results for "${query}"`} + +
+ )} + + {/* Results Grid */} + {loading ? ( + + ) : videos.length === 0 ? ( +
+ + + +

+ No results found +

+

Try different keywords or check your spelling

+
+ ) : ( + <> +
+ {videos.map((video) => ( + + ))} +
+ + {/* Infinite scroll sentinel */} +
+ {loadingMore && } +
+ + {/* End of results */} + {!hasMore && videos.length > 0 && ( +
+ End of results +
+ )} + + )} +
+
+ ); +} diff --git a/frontend/app/search/page.tsx b/frontend/app/search/page.tsx index b2a1014..5b580e4 100644 --- a/frontend/app/search/page.tsx +++ b/frontend/app/search/page.tsx @@ -1,188 +1,21 @@ -export const dynamic = 'force-dynamic'; import { Suspense } from 'react'; -import Link from 'next/link'; -import { cookies } from 'next/headers'; +import ClientSearchPage from './ClientSearchPage'; -interface VideoData { - id: string; - title: string; - uploader: string; - channel_id?: string; - thumbnail: string; - view_count: number; - duration: string; - description: string; - avatar_url?: string; - uploaded_date?: string; -} - -function formatViews(views: number): string { - if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M'; - if (views >= 1000) return (views / 1000).toFixed(1) + 'K'; - return views.toString(); -} - -async function fetchSearchResults(query: string) { - try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/search?q=${encodeURIComponent(query)}`, { cache: 'no-store' }); - if (!res.ok) return []; - return res.json() as Promise; - } catch (e) { - console.error(e); - return []; - } -} - -function SearchSkeleton() { +export default function SearchPage() { return ( -
- {[1, 2, 3, 4].map(i => ( -
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
- ); -} - -async function SearchResults({ query }: { query: string }) { - const videos = await fetchSearchResults(query); - - if (videos.length === 0) { - return ( -
-
🔍
-
- No results found -
-
Try different keywords or check your spelling
+ + Searching...
- ); - } - - return ( -
- {videos.map((v, i) => { - const firstLetter = v.uploader ? v.uploader.charAt(0).toUpperCase() : '?'; - const relativeTime = v.uploaded_date || '3 weeks ago'; - const staggerClass = `stagger-${Math.min(i + 1, 6)}`; - - return ( - - {/* Thumbnail */} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {v.title} { - const img = e.target as HTMLImageElement; - if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') { - img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; - } - }} - /> - {v.duration && ( - - {v.duration} - - )} -
- - {/* Search Result Info */} -
-

- {v.title} -

-
- {formatViews(v.view_count)} views • {relativeTime} -
- - {/* Channel block inline */} -
-
-
- {v.avatar_url ? ( - // eslint-disable-next-line @next/next/no-img-element - { - const img = e.target as HTMLImageElement; - img.onerror = null; - img.src = 'https://i.ytimg.com/img/channels/c_ip_m_default.jpg'; // Fallback to YouTube's default channel avatar - }} - /> - ) : firstLetter} -
- {v.uploader} -
-
- -
- {v.description || 'No description provided.'} -
-
- - ); - })} -
+ }> + + ); -} - -const REGION_LABELS: Record = { - VN: 'Vietnam', - US: 'United States', - JP: 'Japan', - KR: 'South Korea', - IN: 'India', - GB: 'United Kingdom', - GLOBAL: '', -}; - -export default async function SearchPage({ - searchParams, -}: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }> -}) { - const awaitParams = await searchParams; - const q = awaitParams.q as string; - - if (!q) { - return ( -
-
🔍
-
- Search KV-Tube -
-
Enter a search term above to find videos
-
- ); - } - - const cookieStore = await cookies(); - const regionCode = cookieStore.get('region')?.value || 'VN'; - const regionLabel = REGION_LABELS[regionCode] || ''; - const biasedQuery = regionLabel ? `${q} ${regionLabel}` : q; - - return ( -
- }> - - -
- ); -} +} \ No newline at end of file diff --git a/frontend/app/services/youtube.ts b/frontend/app/services/youtube.ts new file mode 100644 index 0000000..95597aa --- /dev/null +++ b/frontend/app/services/youtube.ts @@ -0,0 +1,320 @@ +// Client-side YouTube API Service +// Uses YouTube Data API v3 for metadata and search + +const YOUTUBE_API_KEY = process.env.NEXT_PUBLIC_YOUTUBE_API_KEY || ''; +const YOUTUBE_API_BASE = 'https://www.googleapis.com/youtube/v3'; + +export interface YouTubeVideo { + id: string; + title: string; + description: string; + thumbnail: string; + channelTitle: string; + channelId: string; + publishedAt: string; + viewCount: string; + likeCount: string; + commentCount: string; + duration: string; + tags?: string[]; +} + +export interface YouTubeSearchResult { + id: string; + title: string; + thumbnail: string; + channelTitle: string; + channelId: string; +} + +export interface YouTubeChannel { + id: string; + title: string; + description: string; + thumbnail: string; + subscriberCount: string; + videoCount: string; + customUrl?: string; +} + +export interface YouTubeComment { + id: string; + text: string; + author: string; + authorProfileImage: string; + publishedAt: string; + likeCount: number; + isReply: boolean; + parentId?: string; +} + +// Helper to format ISO 8601 duration to human readable +function formatDuration(isoDuration: string): string { + const match = isoDuration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return isoDuration; + + const hours = parseInt(match[1] || '0', 10); + const minutes = parseInt(match[2] || '0', 10); + const seconds = parseInt(match[3] || '0', 10); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +// Format numbers with K, M suffixes +function formatNumber(num: string | number): string { + const n = typeof num === 'string' ? parseInt(num, 10) : num; + if (isNaN(n)) return '0'; + + if (n >= 1000000) { + return (n / 1000000).toFixed(1) + 'M'; + } + if (n >= 1000) { + return (n / 1000).toFixed(0) + 'K'; + } + return n.toString(); +} + +export class YouTubeAPI { + private apiKey: string; + + constructor(apiKey?: string) { + this.apiKey = apiKey || YOUTUBE_API_KEY; + if (!this.apiKey) { + console.warn('YouTube API key not set. Set NEXT_PUBLIC_YOUTUBE_API_KEY in .env.local'); + } + } + + private async fetch(endpoint: string, params: Record = {}): Promise { + const url = new URL(`${YOUTUBE_API_BASE}${endpoint}`); + url.searchParams.set('key', this.apiKey); + + Object.entries(params).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + + const response = await fetch(url.toString()); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + + // Handle specific quota exceeded error + if (response.status === 403 && errorData?.error?.reason === 'quotaExceeded') { + throw new Error('YouTube API quota exceeded. Please try again later or request a quota increase.'); + } + + // Handle API key expired error + if (response.status === 400 && errorData?.error?.reason === 'API_KEY_INVALID') { + throw new Error('YouTube API key is invalid or expired. Please check your API key.'); + } + + throw new Error(`YouTube API error: ${response.status} ${response.statusText} ${JSON.stringify(errorData)}`); + } + + return response.json(); + } + + // Search for videos + async searchVideos(query: string, maxResults: number = 20): Promise { + const data = await this.fetch('/search', { + part: 'snippet', + q: query, + type: 'video', + maxResults: maxResults.toString(), + order: 'relevance', + }); + + return data.items?.map((item: any) => ({ + id: item.id.videoId, + title: item.snippet.title, + thumbnail: `https://i.ytimg.com/vi/${item.id.videoId}/mqdefault.jpg`, + channelTitle: item.snippet.channelTitle, + channelId: item.snippet.channelId, + })) || []; + } + + // Get video details + async getVideoDetails(videoId: string): Promise { + const data = await this.fetch('/videos', { + part: 'snippet,statistics,contentDetails', + id: videoId, + }); + + const video = data.items?.[0]; + if (!video) return null; + + return { + id: video.id, + title: video.snippet.title, + description: video.snippet.description, + thumbnail: `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`, + channelTitle: video.snippet.channelTitle, + channelId: video.snippet.channelId, + publishedAt: video.snippet.publishedAt, + viewCount: formatNumber(video.statistics?.viewCount || '0'), + likeCount: formatNumber(video.statistics?.likeCount || '0'), + commentCount: formatNumber(video.statistics?.commentCount || '0'), + duration: formatDuration(video.contentDetails?.duration || ''), + tags: video.snippet.tags, + }; + } + + // Get multiple video details + async getVideosDetails(videoIds: string[]): Promise { + if (videoIds.length === 0) return []; + + // API allows max 50 IDs per request + const batchSize = 50; + const results: YouTubeVideo[] = []; + + for (let i = 0; i < videoIds.length; i += batchSize) { + const batch = videoIds.slice(i, i + batchSize).join(','); + const data = await this.fetch('/videos', { + part: 'snippet,statistics,contentDetails', + id: batch, + }); + + const videos = data.items?.map((video: any) => ({ + id: video.id, + title: video.snippet.title, + description: video.snippet.description, + thumbnail: `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`, + channelTitle: video.snippet.channelTitle, + channelId: video.snippet.channelId, + publishedAt: video.snippet.publishedAt, + viewCount: formatNumber(video.statistics?.viewCount || '0'), + likeCount: formatNumber(video.statistics?.likeCount || '0'), + commentCount: formatNumber(video.statistics?.commentCount || '0'), + duration: formatDuration(video.contentDetails?.duration || ''), + tags: video.snippet.tags, + })) || []; + + results.push(...videos); + } + + return results; + } + + // Get channel details + async getChannelDetails(channelId: string): Promise { + const data = await this.fetch('/channels', { + part: 'snippet,statistics', + id: channelId, + }); + + const channel = data.items?.[0]; + if (!channel) return null; + + return { + id: channel.id, + title: channel.snippet.title, + description: channel.snippet.description, + thumbnail: channel.snippet.thumbnails?.high?.url || channel.snippet.thumbnails?.default?.url, + subscriberCount: formatNumber(channel.statistics?.subscriberCount || '0'), + videoCount: formatNumber(channel.statistics?.videoCount || '0'), + customUrl: channel.snippet.customUrl, + }; + } + + // Get channel videos + async getChannelVideos(channelId: string, maxResults: number = 30): Promise { + // First get uploads playlist ID + const channelData = await this.fetch('/channels', { + part: 'contentDetails', + id: channelId, + }); + + const uploadsPlaylistId = channelData.items?.[0]?.contentDetails?.relatedPlaylists?.uploads; + if (!uploadsPlaylistId) return []; + + // Then get videos from that playlist + const playlistData = await this.fetch('/playlistItems', { + part: 'snippet', + playlistId: uploadsPlaylistId, + maxResults: maxResults.toString(), + }); + + return playlistData.items?.map((item: any) => ({ + id: item.snippet.resourceId.videoId, + title: item.snippet.title, + thumbnail: item.snippet.thumbnails?.high?.url || item.snippet.thumbnails?.default?.url, + channelTitle: item.snippet.channelTitle, + channelId: item.snippet.channelId, + })) || []; + } + + // Get comments for a video + async getComments(videoId: string, maxResults: number = 20): Promise { + try { + const data = await this.fetch('/commentThreads', { + part: 'snippet,replies', + videoId: videoId, + maxResults: maxResults.toString(), + order: 'relevance', + textFormat: 'plainText', + }); + + return data.items?.map((item: any) => ({ + id: item.id, + text: item.snippet.topLevelComment.snippet.textDisplay, + author: item.snippet.topLevelComment.snippet.authorDisplayName, + authorProfileImage: item.snippet.topLevelComment.snippet.authorProfileImageUrl, + publishedAt: item.snippet.topLevelComment.snippet.publishedAt, + likeCount: item.snippet.topLevelComment.snippet.likeCount || 0, + isReply: false, + })) || []; + } catch (error) { + // Comments might be disabled + console.warn('Failed to fetch comments:', error); + return []; + } + } + + // Get trending videos + async getTrendingVideos(regionCode: string = 'US', maxResults: number = 20): Promise { + const data = await this.fetch('/videos', { + part: 'snippet,statistics,contentDetails', + chart: 'mostPopular', + regionCode: regionCode, + maxResults: maxResults.toString(), + }); + + return data.items?.map((video: any) => ({ + id: video.id, + title: video.snippet.title, + description: video.snippet.description, + thumbnail: `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`, + channelTitle: video.snippet.channelTitle, + channelId: video.snippet.channelId, + publishedAt: video.snippet.publishedAt, + viewCount: formatNumber(video.statistics?.viewCount || '0'), + likeCount: formatNumber(video.statistics?.likeCount || '0'), + commentCount: formatNumber(video.statistics?.commentCount || '0'), + duration: formatDuration(video.contentDetails?.duration || ''), + tags: video.snippet.tags, + })) || []; + } + + // Get related videos (using search with related query) + async getRelatedVideos(videoId: string, maxResults: number = 10): Promise { + // First get video details to get title for related search + const videoDetails = await this.getVideoDetails(videoId); + if (!videoDetails) return []; + + // Use related query based on video title and channel + const query = `${videoDetails.channelTitle} ${videoDetails.title.split(' ').slice(0, 5).join(' ')}`; + + return this.searchVideos(query, maxResults); + } + + // Get suggestions for search + async getSuggestions(query: string): Promise { + // YouTube doesn't have a suggestions API, so we'll return empty array + // Could implement with autocomplete API if available + return []; + } +} + +// Export singleton instance +export const youtubeAPI = new YouTubeAPI(); \ No newline at end of file diff --git a/frontend/app/shorts/page.tsx b/frontend/app/shorts/page.tsx index 63defbe..80ed1d2 100644 --- a/frontend/app/shorts/page.tsx +++ b/frontend/app/shorts/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { IoHeart, IoHeartOutline, IoChatbubbleOutline, IoShareOutline, IoEllipsisHorizontal, IoMusicalNote, IoRefresh, IoPlay, IoVolumeMute, IoVolumeHigh } from 'react-icons/io5'; +import LoadingSpinner from '../components/LoadingSpinner'; declare global { interface Window { @@ -225,7 +226,7 @@ function ShortCard({ video, isActive }: { video: ShortVideo; isActive: boolean } /> {loading && (
-
+
)} {error && !useFallback && ( @@ -413,15 +414,6 @@ const openBtnStyle: React.CSSProperties = { zIndex: 10, }; -const spinnerStyle: React.CSSProperties = { - width: '40px', - height: '40px', - border: '3px solid #333', - borderTopColor: '#ff0050', - borderRadius: '50%', - animation: 'spin 1s linear infinite', -}; - export default function ShortsPage() { const [shorts, setShorts] = useState([]); const [activeIndex, setActiveIndex] = useState(0); @@ -473,7 +465,7 @@ export default function ShortsPage() { if (loading) return (
-
+
); @@ -492,11 +484,10 @@ export default function ShortsPage() { return (
- {shorts.map((v, i) => )} {loadingMore && (
-
+
)}
@@ -506,5 +497,4 @@ export default function ShortsPage() { const pageStyle: React.CSSProperties = { height: 'calc(100vh - 56px)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f0f0f' }; const scrollContainerStyle: React.CSSProperties = { height: 'calc(100vh - 56px)', overflowY: 'scroll', scrollSnapType: 'y mandatory', background: '#0f0f0f', scrollbarWidth: 'none' }; const spinnerContainerStyle: React.CSSProperties = { borderRadius: '12px', background: 'linear-gradient(180deg, #1a1a1a 0%, #0f0f0f 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }; -const spinCss = '@keyframes spin { to { transform: rotate(360deg); } }'; const hideScrollbarCss = 'div::-webkit-scrollbar { display: none; }'; diff --git a/frontend/app/storage.ts b/frontend/app/storage.ts new file mode 100644 index 0000000..bcc6116 --- /dev/null +++ b/frontend/app/storage.ts @@ -0,0 +1,178 @@ +'use client'; + +// Local storage keys +const HISTORY_KEY = 'kvtube_history'; +const SUBSCRIPTIONS_KEY = 'kvtube_subscriptions'; +const SAVED_VIDEOS_KEY = 'kvtube_saved_videos'; + +export interface HistoryItem { + videoId: string; + title: string; + thumbnail: string; + channelTitle: string; + watchedAt: number; +} + +export interface Subscription { + channelId: string; + channelName: string; + channelAvatar: string; + subscribedAt: number; +} + +export interface SavedVideo { + videoId: string; + title: string; + thumbnail: string; + channelTitle: string; + savedAt: number; +} + +// Get items from localStorage +function getFromStorage(key: string): T[] { + if (typeof window === 'undefined') return []; + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : []; + } catch { + return []; + } +} + +// Save items to localStorage +function saveToStorage(key: string, items: T[]): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(key, JSON.stringify(items)); + } catch (e) { + console.error('Storage error:', e); + } +} + +// ==================== HISTORY ==================== + +export function getHistory(limit: number = 50): HistoryItem[] { + const history = getFromStorage(HISTORY_KEY); + // Sort by most recent first + return history.sort((a, b) => b.watchedAt - a.watchedAt).slice(0, limit); +} + +export function addToHistory(video: { videoId: string; title: string; thumbnail: string; channelTitle?: string }): void { + const history = getFromStorage(HISTORY_KEY); + + // Remove duplicate if exists + const filtered = history.filter(h => h.videoId !== video.videoId); + + // Add new entry at the beginning + const newItem: HistoryItem = { + videoId: video.videoId, + title: video.title, + thumbnail: video.thumbnail, + channelTitle: video.channelTitle || 'Unknown', + watchedAt: Date.now(), + }; + + // Keep only last 100 items + const updated = [newItem, ...filtered].slice(0, 100); + saveToStorage(HISTORY_KEY, updated); +} + +export function removeFromHistory(videoId: string): void { + const history = getFromStorage(HISTORY_KEY); + const filtered = history.filter(h => h.videoId !== videoId); + saveToStorage(HISTORY_KEY, filtered); +} + +export function clearHistory(): void { + saveToStorage(HISTORY_KEY, []); +} + +// ==================== SUBSCRIPTIONS ==================== + +export function getSubscriptions(): Subscription[] { + return getFromStorage(SUBSCRIPTIONS_KEY) + .sort((a, b) => b.subscribedAt - a.subscribedAt); +} + +export function subscribe(channel: { channelId: string; channelName: string; channelAvatar?: string }): void { + const subs = getFromStorage(SUBSCRIPTIONS_KEY); + + // Check if already subscribed + if (subs.some(s => s.channelId === channel.channelId)) return; + + const newSub: Subscription = { + channelId: channel.channelId, + channelName: channel.channelName, + channelAvatar: channel.channelAvatar || '', + subscribedAt: Date.now(), + }; + + saveToStorage(SUBSCRIPTIONS_KEY, [...subs, newSub]); +} + +export function unsubscribe(channelId: string): void { + const subs = getFromStorage(SUBSCRIPTIONS_KEY); + const filtered = subs.filter(s => s.channelId !== channelId); + saveToStorage(SUBSCRIPTIONS_KEY, filtered); +} + +export function isSubscribed(channelId: string): boolean { + const subs = getFromStorage(SUBSCRIPTIONS_KEY); + return subs.some(s => s.channelId === channelId); +} + +export function toggleSubscription(channel: { channelId: string; channelName: string; channelAvatar?: string }): boolean { + if (isSubscribed(channel.channelId)) { + unsubscribe(channel.channelId); + return false; + } else { + subscribe(channel); + return true; + } +} + +// ==================== SAVED VIDEOS ==================== + +export function getSavedVideos(limit: number = 50): SavedVideo[] { + const saved = getFromStorage(SAVED_VIDEOS_KEY); + return saved.sort((a, b) => b.savedAt - a.savedAt).slice(0, limit); +} + +export function saveVideo(video: { videoId: string; title: string; thumbnail: string; channelTitle?: string }): void { + const saved = getFromStorage(SAVED_VIDEOS_KEY); + + // Remove duplicate if exists + const filtered = saved.filter(v => v.videoId !== video.videoId); + + const newVideo: SavedVideo = { + videoId: video.videoId, + title: video.title, + thumbnail: video.thumbnail, + channelTitle: video.channelTitle || 'Unknown', + savedAt: Date.now(), + }; + + const updated = [newVideo, ...filtered]; + saveToStorage(SAVED_VIDEOS_KEY, updated); +} + +export function unsaveVideo(videoId: string): void { + const saved = getFromStorage(SAVED_VIDEOS_KEY); + const filtered = saved.filter(v => v.videoId !== videoId); + saveToStorage(SAVED_VIDEOS_KEY, filtered); +} + +export function isVideoSaved(videoId: string): boolean { + const saved = getFromStorage(SAVED_VIDEOS_KEY); + return saved.some(v => v.videoId === videoId); +} + +export function toggleSaveVideo(video: { videoId: string; title: string; thumbnail: string; channelTitle?: string }): boolean { + if (isVideoSaved(video.videoId)) { + unsaveVideo(video.videoId); + return false; + } else { + saveVideo(video); + return true; + } +} diff --git a/frontend/app/watch/ClientWatchPage.tsx b/frontend/app/watch/ClientWatchPage.tsx new file mode 100644 index 0000000..a1efeec --- /dev/null +++ b/frontend/app/watch/ClientWatchPage.tsx @@ -0,0 +1,996 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import YouTubePlayer from './YouTubePlayer'; +import { getVideoDetailsClient, getRelatedVideosClient, getCommentsClient, searchVideosClient } from '../clientActions'; +import { VideoData } from '../constants'; +import { isSubscribed, toggleSubscription, addToHistory, isVideoSaved, toggleSaveVideo } from '../storage'; +import LoadingSpinner from '../components/LoadingSpinner'; +import Link from 'next/link'; + +// Simple cache for API responses to reduce quota usage +const apiCache = new Map(); +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + +function getCachedData(key: string) { + const cached = apiCache.get(key); + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + return cached.data; + } + return null; +} + +function setCachedData(key: string, data: any) { + apiCache.set(key, { data, timestamp: Date.now() }); + // Clean up old cache entries + if (apiCache.size > 100) { + const oldestKey = apiCache.keys().next().value; + if (oldestKey) { + apiCache.delete(oldestKey); + } + } +} + +// Video Info Section +function VideoInfo({ video }: { video: any }) { + const [expanded, setExpanded] = useState(false); + const [subscribed, setSubscribed] = useState(false); + const [isSaved, setIsSaved] = useState(false); + const [subscribing, setSubscribing] = useState(false); + + // Check subscription and save status on mount + useEffect(() => { + if (video?.channelId) { + setSubscribed(isSubscribed(video.channelId)); + } + if (video?.id) { + setIsSaved(isVideoSaved(video.id)); + } + }, [video?.channelId, video?.id]); + + const handleSubscribe = useCallback(() => { + if (!video?.channelId || subscribing) return; + + setSubscribing(true); + try { + const nowSubscribed = toggleSubscription({ + channelId: video.channelId, + channelName: video.channelTitle, + channelAvatar: '', + }); + setSubscribed(nowSubscribed); + } catch (error) { + console.error('Subscribe error:', error); + } finally { + setSubscribing(false); + } + }, [video?.channelId, video?.channelTitle, subscribing]); + + const handleSave = useCallback(() => { + if (!video?.id) return; + + try { + const nowSaved = toggleSaveVideo({ + videoId: video.id, + title: video.title, + thumbnail: video.thumbnail, + channelTitle: video.channelTitle, + }); + setIsSaved(nowSaved); + } catch (error) { + console.error('Save error:', error); + } + }, [video?.id, video?.title, video?.thumbnail, video?.channelTitle]); + + if (!video) return null; + + const description = video.description || ''; + const hasDescription = description.length > 0; + const shouldTruncate = description.length > 300; + const displayDescription = expanded ? description : description.slice(0, 300) + (shouldTruncate ? '...' : ''); + + // Format date + const formatDate = (dateStr: string) => { + if (!dateStr || dateStr === 'Invalid Date') return ''; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return ''; + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch { + return ''; + } + }; + + // Format view count + const formatViews = (views: string) => { + if (!views || views === '0') return 'No views'; + const num = parseInt(views.replace(/[^0-9]/g, '') || '0'); + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M views'; + if (num >= 1000) return (num / 1000).toFixed(0) + 'K views'; + return num.toLocaleString() + ' views'; + }; + + return ( +
+ {/* Title */} +

+ {video.title || 'Untitled Video'} +

+ + {/* Channel Info & Actions Row */} +
+ {/* Channel - only show name, no avatar */} +
+ {video.channelTitle || 'Unknown Channel'} +
+ + {/* Action Buttons - Subscribe, Share, Save */} +
+ {/* Subscribe Button with Toggle State */} + + + {/* Share Button */} + + + {/* Save Button with Toggle State */} + +
+
+ + {/* Description Box */} +
+ {/* Views and Date */} +
+ {formatViews(video.viewCount)} + {video.publishedAt && formatDate(video.publishedAt) && ( + <> + + {formatDate(video.publishedAt)} + + )} +
+ + {/* Description */} + {hasDescription ? ( +
+ {displayDescription} + {shouldTruncate && ( + + )} +
+ ) : null} + + {/* Tags */} + {video.tags && video.tags.length > 0 && ( +
+ {video.tags.slice(0, 10).map((tag: string, i: number) => ( + + {tag} + + ))} +
+ )} +
+
+ ); +} + +// Mix Playlist Component +function MixPlaylist({ videos, currentIndex, onVideoSelect, title }: { + videos: VideoData[]; + currentIndex: number; + onVideoSelect: (index: number) => void; + title?: string; +}) { + return ( +
+ {/* Header */} +
+
+

+ {title || 'Mix Playlist'} +

+

+ {videos.length} videos • Auto-play is on +

+
+
+ + {/* Video List */} +
+ {videos.map((video, index) => ( +
onVideoSelect(index)} + style={{ + display: 'flex', + gap: '10px', + padding: '8px 12px', + cursor: 'pointer', + backgroundColor: index === currentIndex ? 'var(--yt-active)' : 'transparent', + borderLeft: index === currentIndex ? '3px solid var(--yt-blue)' : '3px solid transparent', + transition: 'background-color 0.2s', + }} + onMouseEnter={(e) => { + if (index !== currentIndex) { + (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.05)'; + } + }} + onMouseLeave={(e) => { + if (index !== currentIndex) { + (e.currentTarget as HTMLElement).style.backgroundColor = 'transparent'; + } + }} + > + {/* Thumbnail with index */} +
+ {video.title} { + (e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/default.jpg`; + }} + /> +
+ {index + 1}/{videos.length} +
+ {index === currentIndex && ( +
+ + + +
+ )} +
+ + {/* Info */} +
+
+ {video.title} +
+
+ {video.uploader} +
+ {video.duration && ( +
+ {video.duration} +
+ )} +
+
+ ))} +
+
+ ); +} + +// Comment Section +function CommentSection({ videoId }: { videoId: string }) { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [showAll, setShowAll] = useState(false); + + useEffect(() => { + const loadComments = async () => { + try { + const data = await getCommentsClient(videoId, 50); + setComments(data); + } catch (error) { + console.error('Failed to load comments:', error); + } finally { + setLoading(false); + } + }; + loadComments(); + }, [videoId]); + + if (loading) { + return ( +
+ Loading comments... +
+ ); + } + + const displayedComments = showAll ? comments : comments.slice(0, 5); + + return ( +
+

+ {comments.length} Comments +

+ + {/* Sort dropdown */} +
+ + + + Sort by +
+ + {/* Comments List */} +
+ {displayedComments.map((comment) => ( +
+ {comment.author_thumbnail ? ( + {comment.author} + ) : null} +
+
+ + {comment.author} + + + {comment.timestamp} + +
+
+ {comment.text} +
+
+ + + +
+
+
+ ))} +
+ + {comments.length > 5 && ( + + )} +
+ ); +} + +export default function ClientWatchPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const videoId = searchParams.get('v'); + const [videoInfo, setVideoInfo] = useState(null); + const [relatedVideos, setRelatedVideos] = useState([]); + const [mixPlaylist, setMixPlaylist] = useState([]); + const [loading, setLoading] = useState(true); + const [currentIndex, setCurrentIndex] = useState(-1); + const [activeTab, setActiveTab] = useState<'upnext' | 'mix'>('upnext'); + const [apiError, setApiError] = useState(null); + + // Scroll to top when video changes or page loads + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'instant' }); + }, [videoId]); + + useEffect(() => { + if (!videoId) return; + + const loadVideoData = async () => { + try { + setLoading(true); + setApiError(null); + + // Check cache for video details + let video = getCachedData(`video_${videoId}`); + if (!video) { + video = await getVideoDetailsClient(videoId); + if (video) setCachedData(`video_${videoId}`, video); + } + setVideoInfo(video); + + // Add to watch history (localStorage) + if (video) { + addToHistory({ + videoId: videoId, + title: video.title, + thumbnail: video.thumbnail, + channelTitle: video.channelTitle, + }); + } + + // Get related videos - use channel name and video title for better results + // Even if video is null, we can still try to get related videos + const searchTerms = video?.title?.split(' ').filter((w: string) => w.length > 3).slice(0, 5).join(' ') || 'music'; + const channelName = video?.channelTitle || ''; + + // Check cache for related videos + const cacheKey = `related_${videoId}_${searchTerms}`; + let relatedResults = getCachedData(cacheKey); + let mixResults = getCachedData(`mix_${videoId}_${searchTerms}`); + + if (!relatedResults || !mixResults) { + // Optimized: Use just 2 search requests instead of 5 to save API quota + [relatedResults, mixResults] = await Promise.all([ + searchVideosClient(`${channelName} ${searchTerms}`, 20), + searchVideosClient(`${searchTerms} mix compilation`, 20), + ]); + + if (relatedResults && relatedResults.length > 0) setCachedData(cacheKey, relatedResults); + if (mixResults && mixResults.length > 0) setCachedData(`mix_${videoId}_${searchTerms}`, mixResults); + } + + // Deduplicate and filter related videos - ensure arrays + const uniqueRelated = Array.isArray(relatedResults) ? relatedResults.filter((v, index, self) => + index === self.findIndex(item => item.id === v.id) && v.id !== videoId + ) : []; + + setCurrentIndex(0); + setRelatedVideos(uniqueRelated); + + // Use remaining videos for mix playlist - ensure array + const uniqueMix = Array.isArray(mixResults) ? mixResults.filter((v, index, self) => + index === self.findIndex(item => item.id === v.id) && + v.id !== videoId && + !uniqueRelated.some(r => r.id === v.id) + ) : []; + + setMixPlaylist(uniqueMix.slice(0, 20)); + + // Set error message if video details failed but we have related videos + if (!video) { + setApiError('Video info unavailable, but you can still browse related videos.'); + } + } catch (error) { + console.error('Failed to load video data:', error); + // Fallback with fewer requests + try { + const fallbackResults = await searchVideosClient('music popular', 20); + setRelatedVideos(Array.isArray(fallbackResults) ? fallbackResults.slice(0, 10) : []); + setMixPlaylist(Array.isArray(fallbackResults) ? fallbackResults.slice(10, 20) : []); + setApiError('Unable to load video details. Showing suggested videos instead.'); + } catch (e: any) { + console.error('Fallback also failed:', e); + // Set empty arrays to show user-friendly message + setRelatedVideos([]); + setMixPlaylist([]); + + // Set user-friendly error message + if (e?.message?.includes('quota exceeded')) { + setApiError('YouTube API quota exceeded. Please try again later.'); + } else if (e?.message?.includes('API key')) { + setApiError('API key issue. Please check configuration.'); + } else { + setApiError('Unable to load related videos. Please try again.'); + } + } + } finally { + setLoading(false); + } + }; + + loadVideoData(); + }, [videoId]); + + const handleVideoSelect = (index: number) => { + const video = activeTab === 'upnext' ? relatedVideos[index] : mixPlaylist[index]; + if (video) { + router.push(`/watch?v=${video.id}`); + } + }; + + const handlePrevious = () => { + if (currentIndex > 0) { + const prevVideo = relatedVideos[currentIndex - 1]; + router.push(`/watch?v=${prevVideo.id}`); + } + }; + + const handleNext = () => { + const playlist = activeTab === 'mix' ? mixPlaylist : relatedVideos; + if (currentIndex < playlist.length - 1) { + const nextVideo = playlist[currentIndex + 1]; + router.push(`/watch?v=${nextVideo.id}`); + } + }; + + const handleVideoEnd = () => { + const playlist = activeTab === 'mix' ? mixPlaylist : relatedVideos; + if (currentIndex < playlist.length - 1) { + handleNext(); + } + }; + + if (!videoId) { + return
No video ID provided
; + } + + if (loading) { + return ; + } + + const currentPlaylist = activeTab === 'mix' ? mixPlaylist : relatedVideos; + + return ( +
+
+ {/* Main Content */} +
+ {/* Video Player */} +
+ +
+ + {/* Player Controls */} +
+ + + +
+ + {/* Video Info */} + + + {/* Comments */} + +
+ + {/* Sidebar */} +
+ {/* Mix Playlist - Always on top */} + + + {/* API Error Message */} + {apiError && ( +
+ {apiError} +
+ )} + + {/* Up Next Section */} +
+
+

+ Up Next +

+ + {relatedVideos.length} videos + +
+
+ {relatedVideos.slice(0, 8).map((video, index) => ( +
handleVideoSelect(index)} + style={{ + display: 'flex', + gap: '10px', + padding: '8px 12px', + cursor: 'pointer', + backgroundColor: index === currentIndex ? 'var(--yt-active)' : 'transparent', + borderLeft: index === currentIndex ? '3px solid var(--yt-blue)' : '3px solid transparent', + transition: 'background-color 0.2s', + }} + onMouseEnter={(e) => { + if (index !== currentIndex) { + (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.05)'; + } + }} + onMouseLeave={(e) => { + if (index !== currentIndex) { + (e.currentTarget as HTMLElement).style.backgroundColor = 'transparent'; + } + }} + > +
+ {video.title} { + (e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`; + }} + /> + {video.duration && ( +
+ {video.duration} +
+ )} +
+
+
+ {video.title} +
+
+ {video.uploader} +
+
+
+ ))} +
+
+
+
+ + {/* Responsive styles */} + +
+ ); +} \ No newline at end of file diff --git a/frontend/app/watch/Comments.tsx b/frontend/app/watch/Comments.tsx deleted file mode 100644 index 0374386..0000000 --- a/frontend/app/watch/Comments.tsx +++ /dev/null @@ -1,198 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import Image from 'next/image'; -import { getVideoComments, CommentData } from '../actions'; - -interface CommentsProps { - videoId: string; -} - -export default function Comments({ videoId }: CommentsProps) { - const [comments, setComments] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - - useEffect(() => { - let isMounted = true; - setIsLoading(true); - setError(false); - setIsExpanded(false); - - getVideoComments(videoId, 40) - .then(data => { - if (isMounted) { - if (data.length === 0) { - setError(true); - } else { - const topLevel = data.filter(c => !c.is_reply); - setComments(topLevel); - } - setIsLoading(false); - } - }) - .catch(err => { - if (isMounted) { - console.error('Failed to load comments:', err); - setError(true); - setIsLoading(false); - } - }); - - return () => { - isMounted = false; - }; - }, [videoId]); - - if (error) { - return ( -
- Comments are turned off or unavailable. -
- ); - } - - if (isLoading) { - return ( -
-

Comments

- {[...Array(3)].map((_, i) => ( -
-
-
-
-
-
-
-
- ))} -
- ); - } - - // Always render all comments; CSS handles mobile collapse via max-height - - return ( -
- {/* Collapsed header for mobile - tappable to expand */} - {!isExpanded && comments.length > 0 && ( -
setIsExpanded(true)} - style={{ - cursor: 'pointer', - display: 'none', // Hidden on desktop, shown via CSS on mobile - alignItems: 'center', - justifyContent: 'space-between', - padding: '12px 16px', - backgroundColor: 'var(--yt-hover)', - borderRadius: '12px', - marginBottom: '16px' - }} - > -
- - Comments - - - {comments.length} - -
-
- {comments[0] && ( - - {comments[0].text.slice(0, 60)}... - - )} - -
-
- )} - - {/* Desktop: always show full title. Mobile: hidden when collapsed */} -

- {comments.length} Comments -

- -
- {comments.map((c) => ( -
-
- {c.author} { - const img = e.target as HTMLImageElement; - img.onerror = null; - img.src = 'https://i.ytimg.com/img/channels/c_ip_m_default.jpg'; // Fallback to YouTube's default channel avatar - }} - /> -
-
-
- - {c.author} - - - {c.timestamp} - -
-
- -
- - {c.likes > 0 && ( -
-
- - {c.likes} -
-
- )} -
-
- ))} -
- - {/* Show more / collapse toggle on mobile */} - {comments.length > 2 && ( - - )} - - {comments.length === 0 && ( -
- No comments found. -
- )} -
- ); -} diff --git a/frontend/app/watch/NextVideoClient.tsx b/frontend/app/watch/NextVideoClient.tsx deleted file mode 100644 index c52b263..0000000 --- a/frontend/app/watch/NextVideoClient.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; - -export default function NextVideoClient({ videoId, listId }: { videoId: string, listId?: string }) { - useEffect(() => { - window.dispatchEvent(new CustomEvent('setNextVideoId', { detail: { videoId, listId } })); - }, [videoId, listId]); - - return null; -} diff --git a/frontend/app/watch/PlaylistPanel.tsx b/frontend/app/watch/PlaylistPanel.tsx deleted file mode 100644 index d279e4b..0000000 --- a/frontend/app/watch/PlaylistPanel.tsx +++ /dev/null @@ -1,150 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import Image from 'next/image'; -import { VideoData } from '../constants'; -import { useEffect, useRef } from 'react'; - -const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; - -interface PlaylistPanelProps { - videos: VideoData[]; - currentVideoId: string; - listId: string; - title: string; -} - -function handleImageError(e: React.SyntheticEvent) { - const img = e.target as HTMLImageElement; - if (img.src !== DEFAULT_THUMBNAIL) { - img.src = DEFAULT_THUMBNAIL; - } -} - -export default function PlaylistPanel({ videos, currentVideoId, listId, title }: PlaylistPanelProps) { - const currentIndex = videos.findIndex(v => v.id === currentVideoId); - const activeItemRef = useRef(null); - - useEffect(() => { - if (activeItemRef.current) { - activeItemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }, [currentVideoId]); - - return ( -
-
-

- {title} -

-
- {currentIndex + 1} / {videos.length} videos -
-
- -
- {videos.map((video, index) => { - const isActive = video.id === currentVideoId; - const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL; - - return ( - -
- {isActive ? '▶' : index + 1} -
- -
- {video.title} - {video.duration && ( -
- {video.duration} -
- )} -
- -
-

- {video.title} -

-
- {video.uploader} -
-
- - ); - })} -
-
- ); -} diff --git a/frontend/app/watch/RelatedVideos.tsx b/frontend/app/watch/RelatedVideos.tsx deleted file mode 100644 index 2d51324..0000000 --- a/frontend/app/watch/RelatedVideos.tsx +++ /dev/null @@ -1,95 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useState, useEffect, useCallback } from 'react'; - -const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; - -interface VideoData { - id: string; - title: string; - uploader: string; - channel_id?: string; - thumbnail: string; - view_count: number; - duration: string; -} - -interface RelatedVideosProps { - initialVideos: VideoData[]; - nextVideoId: string; -} - -function formatViews(views: number): string { - if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M'; - if (views >= 1000) return (views / 1000).toFixed(1) + 'K'; - return views.toString(); -} - -function RelatedVideoItem({ video, index }: { video: VideoData; index: number }) { - const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL; - const views = formatViews(video.view_count); - const staggerClass = `stagger-${Math.min(index + 1, 6)}`; - - const handleImageError = useCallback((e: React.SyntheticEvent) => { - const img = e.target as HTMLImageElement; - if (img.src !== DEFAULT_THUMBNAIL) { - img.src = DEFAULT_THUMBNAIL; - } - }, []); - - return ( - -
- {video.title} - {video.duration && ( -
- {video.duration} -
- )} -
-
- {video.title} - {video.uploader} - {views} views -
- - ); -} - -export default function RelatedVideos({ initialVideos, nextVideoId }: RelatedVideosProps) { - const [videos, setVideos] = useState(initialVideos); - - useEffect(() => { - setVideos(initialVideos); - }, [initialVideos]); - - if (videos.length === 0) { - return
No related videos found.
; - } - - return ( -
- -
-
- UP NEXT -
-
- - {videos.map((video, i) => ( - - ))} -
- ); -} diff --git a/frontend/app/watch/VideoPlayer.tsx b/frontend/app/watch/VideoPlayer.tsx deleted file mode 100644 index 0f6e34d..0000000 --- a/frontend/app/watch/VideoPlayer.tsx +++ /dev/null @@ -1,722 +0,0 @@ -'use client'; - -import { useEffect, useState, useRef } from 'react'; -import { useRouter } from 'next/navigation'; - -declare global { - interface Window { - Hls: any; - } -} - -interface VideoPlayerProps { - videoId: string; - title?: string; -} - -interface QualityOption { - label: string; - height: number; - url: string; - audio_url?: string; - is_hls: boolean; - has_audio?: boolean; -} - -interface StreamInfo { - stream_url: string; - audio_url?: string; - qualities?: QualityOption[]; - best_quality?: number; - error?: string; -} - -function PlayerSkeleton() { - return ( -
-
-
-
-
- ); -} - -export default function VideoPlayer({ videoId, title }: VideoPlayerProps) { - const router = useRouter(); - const videoRef = useRef(null); - const audioRef = useRef(null); - const hlsRef = useRef(null); - const audioHlsRef = useRef(null); - const [error, setError] = useState(null); - const [useFallback, setUseFallback] = useState(false); - const [showControls, setShowControls] = useState(false); - const [qualities, setQualities] = useState([]); - const [currentQuality, setCurrentQuality] = useState(0); - const [showQualityMenu, setShowQualityMenu] = useState(false); - const [hasSeparateAudio, setHasSeparateAudio] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [isBuffering, setIsBuffering] = useState(false); - const [nextVideoId, setNextVideoId] = useState(); - const [nextListId, setNextListId] = useState(); - const [showBackgroundHint, setShowBackgroundHint] = useState(false); - const [wakeLock, setWakeLock] = useState(null); - const [isPiPActive, setIsPiPActive] = useState(false); - const [autoPiPEnabled, setAutoPiPEnabled] = useState(true); - const [showPiPNotification, setShowPiPNotification] = useState(false); - const audioUrlRef = useRef(''); - - useEffect(() => { - const handleSetNextVideo = (e: CustomEvent) => { - if (e.detail && e.detail.videoId) { - setNextVideoId(e.detail.videoId); - if (e.detail.listId !== undefined) { - setNextListId(e.detail.listId); - } else { - setNextListId(undefined); - } - } - }; - window.addEventListener('setNextVideoId', handleSetNextVideo as EventListener); - return () => window.removeEventListener('setNextVideoId', handleSetNextVideo as EventListener); - }, []); - - useEffect(() => { - const script = document.createElement('script'); - script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest'; - script.async = true; - if (!document.querySelector('script[src*="hls.js"]')) { - document.head.appendChild(script); - } - }, []); - - const syncAudio = () => { - const video = videoRef.current; - const audio = audioRef.current; - if (!video || !audio || !hasSeparateAudio) return; - - const isHidden = document.visibilityState === 'hidden'; - - if (Math.abs(video.currentTime - audio.currentTime) > 0.4) { - if (isHidden) { - // When hidden, video might be suspended by the browser. - // Don't pull audio back to the frozen video. Let audio play. - // However, if audio is somehow pausing/lagging, we don't force it here. - } else { - // When visible, normally video is the master timeline. - // BUT, if we just came back from background, audio might be way ahead. - // Instead of always rewinding audio, if video is lagging behind audio (like recovering from sleep), - // we should jump the video forward to catch up to the audio! - if (audio.currentTime > video.currentTime + 1) { - // Video is lagging, jump it forward - video.currentTime = audio.currentTime; - } else { - // Audio is lagging or drifting slightly, snap audio to video - audio.currentTime = video.currentTime; - } - } - } - - if (video.paused && !audio.paused) { - if (!isHidden) { - audio.pause(); - } - } else if (!video.paused && audio.paused) { - // Only force audio to play if it got stuck - audio.play().catch(() => { }); - } - }; - - const handleVisibilityChange = async () => { - const video = videoRef.current; - const audio = audioRef.current; - if (!video) return; - - if (document.visibilityState === 'hidden') { - // Page is hidden - automatically enter Picture-in-Picture if enabled - if (!video.paused && autoPiPEnabled) { - try { - if (document.pictureInPictureEnabled && !document.pictureInPictureElement) { - await video.requestPictureInPicture(); - setIsPiPActive(true); - setShowPiPNotification(true); - setTimeout(() => setShowPiPNotification(false), 3000); - } - } catch (error) { - console.log('Auto PiP failed, using audio fallback:', error); - } - } - // Release wake lock when page is hidden (PiP handles its own wake lock) - releaseWakeLock(); - } else { - // Page is visible again - if (autoPiPEnabled) { - try { - if (document.pictureInPictureElement) { - await document.exitPictureInPicture(); - setIsPiPActive(false); - } - } catch (error) { - console.log('Exit PiP failed:', error); - } - } - - // Re-acquire wake lock when page is visible - if (!video.paused) requestWakeLock(); - - // Recover video position from audio if audio continued playing in background - if (hasSeparateAudio && audio && !audio.paused) { - if (audio.currentTime > video.currentTime + 1) { - video.currentTime = audio.currentTime; - } - if (video.paused) { - video.play().catch(() => { }); - } - } - } - }; - - // Wake Lock API to prevent screen from sleeping during playback - const requestWakeLock = async () => { - try { - if ('wakeLock' in navigator) { - const wakeLockSentinel = await (navigator as any).wakeLock.request('screen'); - setWakeLock(wakeLockSentinel); - - wakeLockSentinel.addEventListener('release', () => { - setWakeLock(null); - }); - } - } catch (err) { - console.log('Wake Lock not supported or failed:', err); - } - }; - - const releaseWakeLock = async () => { - if (wakeLock) { - try { - await wakeLock.release(); - setWakeLock(null); - } catch (err) { - console.log('Failed to release wake lock:', err); - } - } - }; - - // Picture-in-Picture support - const togglePiP = async () => { - const video = videoRef.current; - if (!video) return; - - try { - if (document.pictureInPictureElement) { - await document.exitPictureInPicture(); - setIsPiPActive(false); - } else if (document.pictureInPictureEnabled) { - await video.requestPictureInPicture(); - setIsPiPActive(true); - } - } catch (error) { - console.log('Picture-in-Picture not supported or failed:', error); - } - }; - - // Listen for PiP events - useEffect(() => { - const video = videoRef.current; - if (!video) return; - - const handleEnterPiP = () => setIsPiPActive(true); - const handleLeavePiP = () => setIsPiPActive(false); - - video.addEventListener('enterpictureinpicture', handleEnterPiP); - video.addEventListener('leavepictureinpicture', handleLeavePiP); - - return () => { - video.removeEventListener('enterpictureinpicture', handleEnterPiP); - video.removeEventListener('leavepictureinpicture', handleLeavePiP); - }; - }, []); - - useEffect(() => { - if (useFallback) return; - - // Reset states when videoId changes - setIsLoading(true); - setIsBuffering(false); - setError(null); - setQualities([]); - setShowQualityMenu(false); - - const loadStream = async () => { - try { - const res = await fetch(`/api/get_stream_info?v=${videoId}`); - const data: StreamInfo = await res.json(); - - if (data.error || !data.stream_url) { - throw new Error(data.error || 'No stream URL'); - } - - if (data.qualities && data.qualities.length > 0) { - setQualities(data.qualities); - setCurrentQuality(data.best_quality || data.qualities[0].height); - } - - if (data.audio_url) { - audioUrlRef.current = data.audio_url; - } - - playStream(data.stream_url, data.audio_url); - - } catch (err) { - console.error('Stream load error:', err); - // Only show error after multiple retries, not immediately - setError('Failed to load stream'); - setUseFallback(true); - } - }; - - const tryLoad = (retries = 0) => { - if (window.Hls) { - loadStream(); - } else if (retries < 50) { // Wait up to 5 seconds for HLS to load - setTimeout(() => tryLoad(retries + 1), 100); - } else { - // Fallback to native video player if HLS fails to load - loadStream(); - } - }; - - tryLoad(); - - // Record history once per videoId - fetch('/api/history', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - video_id: videoId, - title: title, - thumbnail: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, - }), - }).catch(err => console.error('Failed to record history', err)); - - return () => { - if (hlsRef.current) { - hlsRef.current.destroy(); - hlsRef.current = null; - } - if (audioHlsRef.current) { - audioHlsRef.current.destroy(); - audioHlsRef.current = null; - } - }; - }, [videoId]); - - useEffect(() => { - const video = videoRef.current; - if (!video) return; - - const handlers = { - play: syncAudio, - pause: syncAudio, - seeked: syncAudio, - timeupdate: syncAudio, - }; - - Object.entries(handlers).forEach(([event, handler]) => { - video.addEventListener(event, handler); - }); - - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - Object.entries(handlers).forEach(([event, handler]) => { - video.removeEventListener(event, handler); - }); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [hasSeparateAudio]); - - const playStream = (streamUrl: string, audioStreamUrl?: string) => { - const video = videoRef.current; - if (!video) return; - - setIsLoading(true); - const isHLS = streamUrl.includes('.m3u8') || streamUrl.includes('manifest'); - const needsSeparateAudio = audioStreamUrl && audioStreamUrl !== ''; - setHasSeparateAudio(!!needsSeparateAudio); - - const handleCanPlay = () => setIsLoading(false); - const handlePlaying = () => { setIsLoading(false); setIsBuffering(false); }; - const handleWaiting = () => setIsBuffering(true); - const handleLoadStart = () => setIsLoading(true); - - if ('mediaSession' in navigator) { - navigator.mediaSession.metadata = new MediaMetadata({ - title: title || 'KV-Tube Video', - artist: 'KV-Tube', - artwork: [ - { src: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, sizes: '480x360', type: 'image/jpeg' } - ] - }); - navigator.mediaSession.setActionHandler('play', () => { - video.play().catch(() => { }); - if (needsSeparateAudio && audioRef.current) audioRef.current.play().catch(() => { }); - }); - navigator.mediaSession.setActionHandler('pause', () => { - video.pause(); - if (needsSeparateAudio && audioRef.current) audioRef.current.pause(); - }); - // Add seek handlers for better background control - navigator.mediaSession.setActionHandler('seekto', (details) => { - if (details.seekTime !== undefined) { - video.currentTime = details.seekTime; - if (needsSeparateAudio && audioRef.current) { - audioRef.current.currentTime = details.seekTime; - } - } - }); - } - - video.addEventListener('canplay', handleCanPlay); - video.addEventListener('playing', handlePlaying); - video.addEventListener('waiting', handleWaiting); - video.addEventListener('loadstart', handleLoadStart); - - // Wake lock event listeners - const handlePlay = () => requestWakeLock(); - const handlePause = () => releaseWakeLock(); - const handleEnded = () => releaseWakeLock(); - - video.addEventListener('play', handlePlay); - video.addEventListener('pause', handlePause); - video.addEventListener('ended', handleEnded); - - if (isHLS && window.Hls && window.Hls.isSupported()) { - if (hlsRef.current) hlsRef.current.destroy(); - - // Enhance buffer to mitigate Safari slow loading and choppiness - const hls = new window.Hls({ - maxBufferLength: 120, - maxMaxBufferLength: 240, - enableWorker: true, - liveSyncDurationCount: 3, - liveMaxLatencyDurationCount: 5, - xhrSetup: (xhr: XMLHttpRequest) => { - xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); - }, - }); - hlsRef.current = hls; - - hls.loadSource(streamUrl); - hls.attachMedia(video); - - hls.on(window.Hls.Events.MANIFEST_PARSED, () => { - video.play().catch(() => { }); - }); - - hls.on(window.Hls.Events.ERROR, (_: any, data: any) => { - if (data.fatal) { - // Try to recover from error first - if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) { - hls.recoverMediaError(); - } else if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) { - // Try to reload the source - hls.loadSource(streamUrl); - } else { - // Only fall back for other fatal errors - setIsLoading(false); - setUseFallback(true); - } - } - }); - } else if (isHLS && video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = streamUrl; - video.onloadedmetadata = () => video.play().catch(() => { }); - } else { - video.src = streamUrl; - video.onloadeddata = () => video.play().catch(() => { }); - } - - if (needsSeparateAudio) { - const audio = audioRef.current; - if (audio) { - const audioIsHLS = audioStreamUrl!.includes('.m3u8') || audioStreamUrl!.includes('manifest'); - - if (audioIsHLS && window.Hls && window.Hls.isSupported()) { - if (audioHlsRef.current) audioHlsRef.current.destroy(); - - const audioHls = new window.Hls({ - maxBufferLength: 120, - maxMaxBufferLength: 240, - enableWorker: true, - liveSyncDurationCount: 3, - liveMaxLatencyDurationCount: 5, - xhrSetup: (xhr: XMLHttpRequest) => { - xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); - }, - }); - audioHlsRef.current = audioHls; - - audioHls.loadSource(audioStreamUrl!); - audioHls.attachMedia(audio); - } else if (audioIsHLS && audio.canPlayType('application/vnd.apple.mpegurl')) { - audio.src = audioStreamUrl!; - } else { - audio.src = audioStreamUrl!; - } - } - } - - video.onended = () => { - setIsLoading(false); - if (nextVideoId) { - const url = nextListId ? `/watch?v=${nextVideoId}&list=${nextListId}` : `/watch?v=${nextVideoId}`; - router.push(url); - } - }; - - // Handle video being paused by browser (e.g., when tab is hidden) - const handlePauseForBackground = () => { - const audio = audioRef.current; - if (!audio || !hasSeparateAudio) return; - - // If the tab is hidden and video was paused, it was likely paused by Chrome saving resources. - // Keep the audio playing! - if (document.visibilityState === 'hidden') { - audio.play().catch(() => { }); - } - }; - - video.addEventListener('pause', handlePauseForBackground); - - // Keep playing when page visibility changes - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible' && !video.paused) { - video.play().catch(() => {}); - if (audioRef.current && audioRef.current.paused) { - audioRef.current.play().catch(() => {}); - } - } - }; - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - video.removeEventListener('pause', handlePauseForBackground); - video.removeEventListener('play', handlePlay); - video.removeEventListener('pause', handlePause); - video.removeEventListener('ended', handleEnded); - document.removeEventListener('visibilitychange', handleVisibilityChange); - releaseWakeLock(); - }; - }; - - const changeQuality = (quality: QualityOption) => { - const video = videoRef.current; - if (!video) return; - - const currentTime = video.currentTime; - const wasPlaying = !video.paused; - - setShowQualityMenu(false); - - const audioUrl = quality.audio_url || audioUrlRef.current; - playStream(quality.url, audioUrl); - setCurrentQuality(quality.height); - - video.currentTime = currentTime; - if (wasPlaying) video.play().catch(() => { }); - }; - - useEffect(() => { - if (!useFallback) return; - - const handleMessage = (event: MessageEvent) => { - if (event.origin !== 'https://www.youtube.com') return; - try { - const data = JSON.parse(event.data); - if (data.event === 'onStateChange' && data.info === 0 && nextVideoId) { - router.push(`/watch?v=${nextVideoId}`); - } - } catch { } - }; - window.addEventListener('message', handleMessage); - return () => window.removeEventListener('message', handleMessage); - }, [useFallback, nextVideoId, router]); - - if (!videoId) { - return
No video ID
; - } - - if (useFallback) { - return ( -
setShowControls(true)} onMouseLeave={() => setShowControls(false)}> -