feat: latest changes from local development
This commit is contained in:
parent
f6bbfc981a
commit
acdafcfe8c
53 changed files with 5349 additions and 3312 deletions
|
|
@ -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
|
|
||||||
CUsersAdminDocumentskv-tubepage.html
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
||||||
cat: can't open '/app/frontend/.next/required-server-files.js': No such file or directory
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
# ---- Backend Builder ----
|
# ---- Backend Builder ----
|
||||||
FROM golang:1.25-alpine AS backend-builder
|
FROM golang:1.25-alpine AS backend-builder
|
||||||
ENV GOTOOLCHAIN=local
|
ENV GOTOOLCHAIN=local
|
||||||
|
ENV GOPROXY=https://proxy.golang.org,direct
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache git gcc musl-dev
|
RUN apk add --no-cache git gcc musl-dev
|
||||||
COPY backend/go.mod backend/go.sum ./
|
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 (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
|
RUN go mod download
|
||||||
COPY backend/ ./
|
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 ----
|
# ---- Frontend Builder ----
|
||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:20-alpine AS frontend-deps
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ We recommend using **Container Manager** (DSM 7.2+) or **Docker** (DSM 6/7.1) fo
|
||||||
|
|
||||||
### 1. Prerequisites
|
### 1. Prerequisites
|
||||||
- **Container Manager** or **Docker** package installed from Package Center.
|
- **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`).
|
- Create a folder named `kv-tube` in your `docker` shared folder (e.g., `/volume1/docker/kv-tube`).
|
||||||
|
|
||||||
### 2. Using Container Manager (Recommended)
|
### 2. Using Container Manager (Recommended)
|
||||||
|
|
@ -75,7 +75,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5011:3000"
|
- "5011:3000"
|
||||||
- "8080:8080"
|
- "8981:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -90,7 +90,7 @@ services:
|
||||||
### 3. Accessing the App
|
### 3. Accessing the App
|
||||||
The application will be accessible at:
|
The application will be accessible at:
|
||||||
- **Frontend**: `http://<your-nas-ip>:5011`
|
- **Frontend**: `http://<your-nas-ip>:5011`
|
||||||
- **Backend API**: `http://<your-nas-ip>:8080`
|
- **Backend API**: `http://<your-nas-ip>:8981`
|
||||||
- **Mobile Users**: Add to Home Screen via Safari for the full PWA experience with background playback.
|
- **Mobile Users**: Add to Home Screen via Safari for the full PWA experience with background playback.
|
||||||
|
|
||||||
### 4. Volume Permissions (If Needed)
|
### 4. Volume Permissions (If Needed)
|
||||||
|
|
|
||||||
77
backend/backend.log
Normal file
77
backend/backend.log
Normal file
|
|
@ -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"
|
||||||
BIN
backend/kv-tube-new → backend/kv-tube
Normal file → Executable file
BIN
backend/kv-tube-new → backend/kv-tube
Normal file → Executable file
Binary file not shown.
Binary file not shown.
|
|
@ -1,17 +1,11 @@
|
||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"kvtube-go/services"
|
"kvtube-go/services"
|
||||||
|
|
||||||
|
|
@ -47,57 +41,6 @@ func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
||||||
return false
|
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 {
|
func SetupRouter() *gin.Engine {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
|
@ -117,26 +60,26 @@ func SetupRouter() *gin.Engine {
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/api/health", func(c *gin.Context) {
|
// API Routes - Using yt-dlp for video operations
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
// API Routes
|
|
||||||
api := r.Group("/api")
|
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("/search", handleSearch)
|
||||||
api.GET("/trending", handleTrending)
|
api.GET("/trending", handleTrending)
|
||||||
api.GET("/get_stream_info", handleGetStreamInfo)
|
api.GET("/video/:id", handleGetVideoInfo)
|
||||||
api.GET("/download", handleDownload)
|
api.GET("/video/:id/qualities", handleGetQualities)
|
||||||
api.GET("/download-file", handleDownloadFile)
|
api.GET("/video/:id/related", handleRelatedVideos)
|
||||||
api.GET("/transcript", handleTranscript)
|
api.GET("/video/:id/comments", handleComments)
|
||||||
api.GET("/comments", handleComments)
|
api.GET("/video/:id/download", handleDownload)
|
||||||
api.GET("/channel/videos", handleChannelVideos)
|
|
||||||
|
// Channel endpoints
|
||||||
api.GET("/channel/info", handleChannelInfo)
|
api.GET("/channel/info", handleChannelInfo)
|
||||||
api.GET("/related", handleRelatedVideos)
|
api.GET("/channel/videos", handleChannelVideos)
|
||||||
api.GET("/formats", handleGetFormats)
|
|
||||||
api.GET("/qualities", handleGetQualities)
|
|
||||||
api.GET("/stream", handleGetStreamByQuality)
|
|
||||||
|
|
||||||
// History routes
|
// History routes
|
||||||
api.POST("/history", handlePostHistory)
|
api.POST("/history", handlePostHistory)
|
||||||
|
|
@ -150,11 +93,10 @@ func SetupRouter() *gin.Engine {
|
||||||
api.GET("/subscriptions", handleGetSubscriptions)
|
api.GET("/subscriptions", handleGetSubscriptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.GET("/video_proxy", handleVideoProxy)
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Video search endpoint
|
||||||
func handleSearch(c *gin.Context) {
|
func handleSearch(c *gin.Context) {
|
||||||
query := c.Query("q")
|
query := c.Query("q")
|
||||||
if query == "" {
|
if query == "" {
|
||||||
|
|
@ -162,21 +104,15 @@ func handleSearch(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate search query for security
|
limitStr := c.Query("limit")
|
||||||
if err := validateSearchQuery(query); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := 20
|
limit := 20
|
||||||
if l := c.Query("limit"); l != "" {
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||||
if parsed, err := strconv.Atoi(l); err == nil {
|
limit = l
|
||||||
limit = parsed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := services.SearchVideos(query, limit)
|
results, err := services.SearchVideos(query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("Search error: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -184,489 +120,189 @@ func handleSearch(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, results)
|
c.JSON(http.StatusOK, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trending videos endpoint
|
||||||
func handleTrending(c *gin.Context) {
|
func handleTrending(c *gin.Context) {
|
||||||
// Basic mock implementation for now
|
limitStr := c.Query("limit")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
limit := 20
|
||||||
"data": []gin.H{
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||||
{
|
limit = l
|
||||||
"id": "trending",
|
}
|
||||||
"title": "Currently Trending",
|
|
||||||
"icon": "fire",
|
|
||||||
"videos": []gin.H{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGetStreamInfo(c *gin.Context) {
|
// Use popular music search as trending
|
||||||
videoID := c.Query("v")
|
results, err := services.SearchVideos("popular music trending", limit)
|
||||||
if videoID == "" {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
log.Printf("Trending error: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trending videos"})
|
||||||
return
|
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 {
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build quality options for frontend
|
c.JSON(http.StatusOK, video)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get video qualities
|
||||||
func handleGetQualities(c *gin.Context) {
|
func handleGetQualities(c *gin.Context) {
|
||||||
videoID := c.Query("v")
|
videoID := c.Param("id")
|
||||||
if videoID == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
|
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
|
||||||
if err != nil {
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"qualities": qualities,
|
||||||
"qualities": result,
|
"audio_url": audioURL,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get related videos
|
||||||
func handleRelatedVideos(c *gin.Context) {
|
func handleRelatedVideos(c *gin.Context) {
|
||||||
videoID := c.Query("v")
|
videoID := c.Param("id")
|
||||||
title := c.Query("title")
|
if videoID == "" {
|
||||||
uploader := c.Query("uploader")
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||||
|
|
||||||
if title == "" && videoID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID or Title required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limitStr := c.Query("limit")
|
limitStr := c.Query("limit")
|
||||||
limit := 10
|
limit := 15
|
||||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||||
limit = l
|
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 {
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, videos)
|
c.JSON(http.StatusOK, related)
|
||||||
}
|
|
||||||
|
|
||||||
func handleTranscript(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not Implemented"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get video comments
|
||||||
func handleComments(c *gin.Context) {
|
func handleComments(c *gin.Context) {
|
||||||
videoID := c.Query("v")
|
videoID := c.Param("id")
|
||||||
if videoID == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
limitStr := c.Query("limit")
|
||||||
limit := 20
|
limit := 20
|
||||||
if l := c.Query("limit"); l != "" {
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
limit = l
|
||||||
limit = parsed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
comments, err := services.GetComments(videoID, limit)
|
comments, err := services.GetComments(videoID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("GetComments Error: %v", err)
|
log.Printf("GetComments error: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get comments"})
|
c.JSON(http.StatusOK, []interface{}{}) // Return empty array instead of error
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, comments)
|
c.JSON(http.StatusOK, comments)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleChannelInfo(c *gin.Context) {
|
// Get download URL
|
||||||
channelID := c.Query("id")
|
func handleDownload(c *gin.Context) {
|
||||||
if channelID == "" {
|
videoID := c.Param("id")
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"})
|
if videoID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := services.GetChannelInfo(channelID)
|
formatID := c.Query("format")
|
||||||
|
|
||||||
|
downloadInfo, err := services.GetDownloadURL(videoID, formatID)
|
||||||
if err != nil {
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel info"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, info)
|
c.JSON(http.StatusOK, channelInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get channel videos
|
||||||
func handleChannelVideos(c *gin.Context) {
|
func handleChannelVideos(c *gin.Context) {
|
||||||
channelID := c.Query("id")
|
channelID := c.Query("id")
|
||||||
if channelID == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limitStr := c.Query("limit")
|
limitStr := c.Query("limit")
|
||||||
limit := 30
|
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
|
limit = l
|
||||||
}
|
}
|
||||||
|
|
||||||
videos, err := services.GetChannelVideos(channelID, limit)
|
videos, err := services.GetChannelVideos(channelID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("GetChannelVideos Error: %v", err)
|
log.Printf("GetChannelVideos error: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos", "details": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, videos)
|
c.JSON(http.StatusOK, videos)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleVideoProxy(c *gin.Context) {
|
// History handlers
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handlePostHistory(c *gin.Context) {
|
func handlePostHistory(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
VideoID string `json:"video_id"`
|
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
|
// 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
|
var results []services.VideoData
|
||||||
for _, h := range history {
|
for _, h := range history {
|
||||||
results = append(results, services.VideoData{
|
results = append(results, services.VideoData{
|
||||||
ID: h.ID,
|
ID: h.ID,
|
||||||
Title: h.Title,
|
Title: h.Title,
|
||||||
Thumbnail: h.Thumbnail,
|
Thumbnail: h.Thumbnail,
|
||||||
Uploader: "History", // Just a placeholder
|
Uploader: "History",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -737,6 +372,7 @@ func handleGetSuggestions(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, suggestions)
|
c.JSON(http.StatusOK, suggestions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscription handlers
|
||||||
func handleSubscribe(c *gin.Context) {
|
func handleSubscribe(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
|
|
@ -804,3 +440,7 @@ func handleGetSubscriptions(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, subs)
|
c.JSON(http.StatusOK, subs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logPrintf(format string, v ...interface{}) {
|
||||||
|
log.Printf(format, v...)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"kvtube-go/models"
|
"kvtube-go/models"
|
||||||
)
|
)
|
||||||
|
|
@ -67,30 +66,10 @@ func GetHistory(limit int) ([]HistoryVideo, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSuggestions retrieves suggestions based on the user's recent history
|
// 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) {
|
func GetSuggestions(limit int) ([]VideoData, error) {
|
||||||
// 1. Get the 3 most recently watched videos to extract keywords
|
// Return empty results - suggestions are now handled client-side
|
||||||
history, err := GetHistory(3)
|
// Frontend should use YouTube API for suggestions
|
||||||
if err != nil || len(history) == 0 {
|
return []VideoData{}, nil
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,8 @@ func sanitizeVideoData(entry YtDlpEntry) VideoData {
|
||||||
|
|
||||||
thumbnail := ""
|
thumbnail := ""
|
||||||
if entry.ID != "" {
|
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{
|
return VideoData{
|
||||||
|
|
@ -251,15 +252,23 @@ func GetVideoInfo(videoID string) (*VideoData, error) {
|
||||||
url,
|
url,
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := "video_info:" + videoID
|
// Skip cache for now to avoid corrupted data issues
|
||||||
out, err := RunYtDlpCached(cacheKey, 3600, args...) // Cache for 1 hour
|
out, err := RunYtDlp(args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("yt-dlp failed for %s: %v", videoID, err)
|
||||||
return nil, 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
|
var entry YtDlpEntry
|
||||||
if err := json.Unmarshal(out, &entry); err != nil {
|
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)
|
data := sanitizeVideoData(entry)
|
||||||
|
|
@ -268,6 +277,13 @@ func GetVideoInfo(videoID string) (*VideoData, error) {
|
||||||
return &data, nil
|
return &data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
type QualityFormat struct {
|
type QualityFormat struct {
|
||||||
FormatID string `json:"format_id"`
|
FormatID string `json:"format_id"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
|
|
||||||
33
fix_urls.js
33
fix_urls.js
|
|
@ -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");
|
|
||||||
621
frontend/app/ClientHomePage.tsx
Normal file
621
frontend/app/ClientHomePage.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Link href={`/watch?v=${video.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
marginBottom: '12px',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{!imgLoaded && !imgError && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<LoadingSpinner size="small" color="white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!imgError ? (
|
||||||
|
<img
|
||||||
|
src={currentSrc}
|
||||||
|
alt={video.title}
|
||||||
|
onError={handleError}
|
||||||
|
onLoad={() => setImgLoaded(true)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: imgLoaded ? 'block' : 'none',
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#333',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#666',
|
||||||
|
}}>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Duration badge */}
|
||||||
|
{video.duration && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8px',
|
||||||
|
right: '8px',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '3px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0)',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Title - max 2 lines */}
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
margin: '0 0 4px 0',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
}}>
|
||||||
|
{video.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Channel name */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}>
|
||||||
|
{video.uploader}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Views and time */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
}}>
|
||||||
|
{(video.view_count ?? 0) > 0 && <span>{formatViews(video.view_count ?? 0)}</span>}
|
||||||
|
{(video.view_count ?? 0) > 0 && <span>•</span>}
|
||||||
|
<span>{video.upload_date || video.publishedAt || getStableTimeAgo(video.id)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category Pills Component
|
||||||
|
function CategoryPills({
|
||||||
|
categories,
|
||||||
|
currentCategory,
|
||||||
|
onCategoryChange
|
||||||
|
}: {
|
||||||
|
categories: string[];
|
||||||
|
currentCategory: string;
|
||||||
|
onCategoryChange: (category: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
padding: '16px 0',
|
||||||
|
borderBottom: '1px solid var(--yt-border)',
|
||||||
|
marginBottom: '24px',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
}}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => onCategoryChange(category)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: currentCategory === category ? 'var(--yt-text-primary)' : 'var(--yt-hover)',
|
||||||
|
color: currentCategory === category ? 'var(--yt-background)' : 'var(--yt-text-primary)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: '14px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentCategory !== category) {
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = 'var(--yt-active)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentCategory !== category) {
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = 'var(--yt-hover)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading Skeleton
|
||||||
|
function VideoSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<div style={{
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
borderRadius: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
height: '14px',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
width: '90%',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
height: '12px',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
borderRadius: '4px',
|
||||||
|
width: '60%',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<VideoData[]>([]);
|
||||||
|
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<string, string> = {
|
||||||
|
'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 (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: '0 24px 24px',
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||||
|
{/* Category Pills */}
|
||||||
|
<CategoryPills
|
||||||
|
categories={categories}
|
||||||
|
currentCategory={currentCategory}
|
||||||
|
onCategoryChange={handleCategoryChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Video Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||||
|
gap: '0 24px',
|
||||||
|
}}>
|
||||||
|
{[...Array(12)].map((_, i) => (
|
||||||
|
<VideoSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||||
|
gap: '0 24px',
|
||||||
|
}}>
|
||||||
|
{videos.map((video) => (
|
||||||
|
<VideoCard key={video.id} video={video} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Sentinel for Infinite Scroll */}
|
||||||
|
<div id="scroll-sentinel" style={{ height: '100px', width: '100%' }} />
|
||||||
|
|
||||||
|
{/* Loading More Indicator */}
|
||||||
|
{loadingMore && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '48px 0',
|
||||||
|
}}>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of Results */}
|
||||||
|
{!hasMore && videos.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '48px 0',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
You've reached the end
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{videos.length === 0 && !loading && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '400px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
}}>
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style={{ marginBottom: '16px', opacity: 0.5 }}>
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 style={{ fontSize: '16px', marginBottom: '8px' }}>No videos found</h3>
|
||||||
|
<p style={{ fontSize: '14px' }}>Try selecting a different category</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Animations */}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,9 +27,11 @@ function formatSubscribers(count: number): string {
|
||||||
|
|
||||||
// We no longer need getAvatarColor as we now use the global --yt-avatar-bg
|
// 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) {
|
async function getChannelInfo(id: string) {
|
||||||
try {
|
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;
|
if (!res.ok) return null;
|
||||||
return res.json() as Promise<ChannelInfo>;
|
return res.json() as Promise<ChannelInfo>;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -40,7 +42,7 @@ async function getChannelInfo(id: string) {
|
||||||
|
|
||||||
async function getChannelVideos(id: string) {
|
async function getChannelVideos(id: string) {
|
||||||
try {
|
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 [];
|
if (!res.ok) return [];
|
||||||
return res.json() as Promise<VideoData[]>;
|
return res.json() as Promise<VideoData[]>;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
271
frontend/app/clientActions.ts
Normal file
271
frontend/app/clientActions.ts
Normal file
|
|
@ -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<VideoData[]> {
|
||||||
|
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<VideoData | null> {
|
||||||
|
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<VideoData[]> {
|
||||||
|
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<VideoData[]> {
|
||||||
|
// Map region codes to search queries for region-specific trending
|
||||||
|
const regionNames: Record<string, string> = {
|
||||||
|
'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<any[]> {
|
||||||
|
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<any | null> {
|
||||||
|
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<VideoData[]> {
|
||||||
|
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<VideoData[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ export default function HamburgerMenu() {
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import VideoCard from './VideoCard';
|
import VideoCard from './VideoCard';
|
||||||
import { fetchMoreVideos } from '../actions';
|
import { fetchMoreVideos } from '../actions';
|
||||||
import { VideoData } from '../constants';
|
import { VideoData } from '../constants';
|
||||||
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialVideos: VideoData[];
|
initialVideos: VideoData[];
|
||||||
|
|
@ -100,16 +101,7 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi
|
||||||
|
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
{isLoading && (
|
{isLoading && <LoadingSpinner />}
|
||||||
<div style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
border: '3px solid var(--yt-border)',
|
|
||||||
borderTopColor: 'var(--yt-brand-red)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'spin 1s linear infinite'
|
|
||||||
}}></div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
75
frontend/app/components/LoadingSpinner.tsx
Normal file
75
frontend/app/components/LoadingSpinner.tsx
Normal file
|
|
@ -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 = (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: `${spinner}px`,
|
||||||
|
height: `${spinner}px`,
|
||||||
|
border: `${border}px solid ${borderColor}`,
|
||||||
|
borderTop: `${border}px solid ${spinnerColor}`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
{text && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
}}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullScreen) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
}}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ export default function MobileNav() {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||||
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export default function RegionSelector() {
|
||||||
setSelected(code);
|
setSelected(code);
|
||||||
setRegionCookie(code);
|
setRegionCookie(code);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
// Dispatch custom event for immediate notification
|
||||||
|
window.dispatchEvent(new CustomEvent('regionchange', { detail: { region: code } }));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default function Sidebar() {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||||
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,8 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import { VideoData } from '@/app/constants';
|
||||||
interface VideoData {
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatViews(views: number): string {
|
function formatViews(views: number): string {
|
||||||
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
||||||
|
|
@ -23,10 +12,10 @@ function formatViews(views: number): string {
|
||||||
return views.toString();
|
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 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;
|
const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
return times[index];
|
return times[hash % times.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
@ -34,7 +23,7 @@ import { memo } from 'react';
|
||||||
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
|
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
|
||||||
|
|
||||||
function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
|
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 [isNavigating, setIsNavigating] = useState(false);
|
||||||
const destination = video.list_id ? `/watch?v=${video.id}&list=${video.list_id}` : `/watch?v=${video.id}`;
|
const destination = video.list_id ? `/watch?v=${video.id}&list=${video.list_id}` : `/watch?v=${video.id}`;
|
||||||
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
||||||
|
|
@ -89,13 +78,7 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
zIndex: 10
|
zIndex: 10
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<LoadingSpinner color="white" />
|
||||||
width: '40px', height: '40px',
|
|
||||||
border: '3px solid rgba(255,255,255,0.3)',
|
|
||||||
borderTopColor: '#fff',
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'spin 1s linear infinite'
|
|
||||||
}} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -111,15 +94,15 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
|
||||||
<div style={{ marginTop: '4px' }}>
|
<div style={{ marginTop: '4px' }}>
|
||||||
{video.channel_id ? (
|
{video.channel_id ? (
|
||||||
<Link href={`/channel/${video.channel_id}`} style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', display: 'block', textDecoration: 'none', transition: 'color 0.2s' }} className="channel-link-hover">
|
<Link href={`/channel/${video.channel_id}`} style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', display: 'block', textDecoration: 'none', transition: 'color 0.2s' }} className="channel-link-hover">
|
||||||
{video.uploader}
|
{video.uploader || video.channelTitle || 'Unknown'}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', display: 'block' }}>
|
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', display: 'block' }}>
|
||||||
{video.uploader}
|
{video.uploader || video.channelTitle || 'Unknown'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||||
{formatViews(video.view_count)} views • {relativeTime}
|
{formatViews(video.view_count ?? 0)} views • {relativeTime}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export interface VideoData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
uploader: string;
|
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
view_count: number;
|
channelTitle?: string;
|
||||||
|
channelId?: string;
|
||||||
|
viewCount?: string;
|
||||||
|
publishedAt?: string;
|
||||||
duration: 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;
|
avatar_url?: string;
|
||||||
list_id?: string;
|
list_id?: string;
|
||||||
is_mix?: boolean;
|
is_mix?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
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';
|
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<HTMLImageElement>) => {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
if (img.src !== DEFAULT_THUMBNAIL) {
|
||||||
|
img.src = DEFAULT_THUMBNAIL;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={destination}
|
||||||
|
className="videocard-container card-hover-lift"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
|
<img
|
||||||
|
src={thumbnailSrc}
|
||||||
|
alt={video.title}
|
||||||
|
className="videocard-thumb"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8px',
|
||||||
|
right: '8px',
|
||||||
|
background: 'rgba(0,0,0,0.8)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
Saved
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="videocard-info" style={{ padding: '0 4px' }}>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: '20px',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}>
|
||||||
|
{video.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
{video.channelTitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const [history, setHistory] = useState<VideoData[]>([]);
|
const [history, setHistory] = useState<VideoData[]>([]);
|
||||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||||
|
const [savedVideos, setSavedVideos] = useState<SavedVideo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
|
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api';
|
||||||
const [historyRes, subsRes] = await Promise.all([
|
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(`${apiBase}/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}/subscriptions`, { cache: 'no-store' })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const historyData = await historyRes.json();
|
const historyData = await historyRes.json();
|
||||||
const subsData = await subsRes.json();
|
const subsData = await subsRes.json();
|
||||||
|
const savedData = getSavedVideos(20);
|
||||||
|
|
||||||
setHistory(Array.isArray(historyData) ? historyData : []);
|
setHistory(Array.isArray(historyData) ? historyData : []);
|
||||||
setSubscriptions(Array.isArray(subsData) ? subsData : []);
|
setSubscriptions(Array.isArray(subsData) ? subsData : []);
|
||||||
|
setSavedVideos(savedData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch library data:', err);
|
console.error('Failed to fetch library data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -174,15 +246,8 @@ export default function LibraryPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '48px', textAlign: 'center' }}>
|
<div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
|
||||||
<div style={{
|
<LoadingSpinner />
|
||||||
width: '40px', height: '40px',
|
|
||||||
border: '3px solid var(--yt-border)',
|
|
||||||
borderTopColor: 'var(--yt-brand-red)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'spin 1s linear infinite',
|
|
||||||
margin: '0 auto'
|
|
||||||
}}></div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +257,7 @@ export default function LibraryPage() {
|
||||||
{subscriptions.length > 0 && (
|
{subscriptions.length > 0 && (
|
||||||
<section style={{ marginBottom: '40px' }}>
|
<section style={{ marginBottom: '40px' }}>
|
||||||
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||||
Subscriptions
|
Sub
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||||
{subscriptions.map((sub) => (
|
{subscriptions.map((sub) => (
|
||||||
|
|
@ -202,6 +267,23 @@ export default function LibraryPage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{savedVideos.length > 0 && (
|
||||||
|
<section style={{ marginBottom: '40px' }}>
|
||||||
|
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||||
|
Saved Videos
|
||||||
|
</h2>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
{savedVideos.map((video) => (
|
||||||
|
<SavedVideoCard key={video.videoId} video={video} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||||
Watch History
|
Watch History
|
||||||
|
|
|
||||||
|
|
@ -2,30 +2,37 @@
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
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';
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api';
|
||||||
|
|
||||||
interface VideoData {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
uploader: string;
|
|
||||||
channel_id: string;
|
|
||||||
thumbnail: string;
|
|
||||||
view_count: number;
|
|
||||||
duration: string;
|
|
||||||
uploaded_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Subscription {
|
interface Subscription {
|
||||||
id: number;
|
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
channel_name: string;
|
channel_name: string;
|
||||||
channel_avatar: string;
|
channel_avatar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_THUMBNAIL = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180"><rect fill="%23333" width="320" height="180"/><text x="160" y="90" text-anchor="middle" fill="%23666" font-family="Arial" font-size="14">No thumbnail</text></svg>';
|
||||||
|
|
||||||
interface ChannelVideos {
|
interface ChannelVideos {
|
||||||
subscription: Subscription;
|
subscription: Subscription;
|
||||||
videos: VideoData[];
|
videos: VideoData[];
|
||||||
|
channelInfo: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch subscriptions from backend API
|
||||||
|
async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||||
|
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;
|
const INITIAL_ROWS = 2;
|
||||||
|
|
@ -38,12 +45,6 @@ function formatViews(views: number): string {
|
||||||
return views.toString();
|
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 }) {
|
function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVideos: ChannelVideos; defaultExpanded?: boolean }) {
|
||||||
const { subscription, videos } = channelVideos;
|
const { subscription, videos } = channelVideos;
|
||||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
|
@ -85,12 +86,17 @@ function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVid
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'}
|
{subscription.channel_avatar ? (
|
||||||
</div>
|
<img src={subscription.channel_avatar} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
<h2 style={{ fontSize: '18px', fontWeight: '500', color: 'var(--yt-text-primary)' }}>
|
) : (
|
||||||
{subscription.channel_name || subscription.channel_id}
|
subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'
|
||||||
</h2>
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: '500', color: 'var(--yt-text-primary)', textAlign: 'center' }}>
|
||||||
|
{subscription.channel_name || subscription.channel_id}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -100,7 +106,7 @@ function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVid
|
||||||
padding: '0 12px',
|
padding: '0 12px',
|
||||||
}}>
|
}}>
|
||||||
{displayedVideos.map((video) => {
|
{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 destination = `/watch?v=${video.id}`;
|
||||||
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
||||||
|
|
||||||
|
|
@ -142,7 +148,7 @@ function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVid
|
||||||
{video.title}
|
{video.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', margin: 0 }}>
|
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', margin: 0 }}>
|
||||||
{formatViews(video.view_count)} views • {relativeTime}
|
{video.viewCount || formatViews(video.view_count || 0)} views • {relativeTime}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
@ -189,27 +195,35 @@ export default function SubscriptionsPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const subsRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
|
const subs = await fetchSubscriptions();
|
||||||
const subsData = await subsRes.json();
|
|
||||||
const subs = Array.isArray(subsData) ? subsData : [];
|
|
||||||
|
|
||||||
const channelVideos: ChannelVideos[] = [];
|
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 }))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setChannelsVideos(channelVideos);
|
// 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const validResults = results.filter((r): r is ChannelVideos => r !== null);
|
||||||
|
|
||||||
|
setChannelsVideos(validResults);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch subscriptions:', err);
|
console.error('Failed to fetch subscriptions:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -221,15 +235,8 @@ export default function SubscriptionsPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '48px', textAlign: 'center' }}>
|
<div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
|
||||||
<div style={{
|
<LoadingSpinner />
|
||||||
width: '40px', height: '40px',
|
|
||||||
border: '3px solid var(--yt-border)',
|
|
||||||
borderTopColor: 'var(--yt-brand-red)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'spin 1s linear infinite',
|
|
||||||
margin: '0 auto'
|
|
||||||
}}></div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -239,13 +246,28 @@ export default function SubscriptionsPage() {
|
||||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--yt-text-secondary)' }}>
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--yt-text-secondary)' }}>
|
||||||
<h2 style={{ marginBottom: '16px', color: 'var(--yt-text-primary)' }}>No subscriptions yet</h2>
|
<h2 style={{ marginBottom: '16px', color: 'var(--yt-text-primary)' }}>No subscriptions yet</h2>
|
||||||
<p>Subscribe to channels to see their latest videos here</p>
|
<p>Subscribe to channels to see their latest videos here</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: 'var(--yt-brand-red)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '20px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Discover videos
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', maxWidth: '1400px', margin: '0 auto' }}>
|
<div style={{ padding: '12px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||||
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px', padding: '0 12px' }}>Subscriptions</h1>
|
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px', padding: '0 12px' }}>Sub</h1>
|
||||||
|
|
||||||
{channelsVideos.map((channelData) => (
|
{channelsVideos.map((channelData) => (
|
||||||
<ChannelSection key={channelData.subscription.channel_id} channelVideos={channelData} />
|
<ChannelSection key={channelData.subscription.channel_id} channelVideos={channelData} />
|
||||||
|
|
|
||||||
|
|
@ -1,160 +1,11 @@
|
||||||
import Link from 'next/link';
|
import { Suspense } from 'react';
|
||||||
import { cookies } from 'next/headers';
|
import ClientHomePage from './ClientHomePage';
|
||||||
import InfiniteVideoGrid from './components/InfiniteVideoGrid';
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
import VideoCard from './components/VideoCard';
|
|
||||||
import {
|
|
||||||
getSearchVideos,
|
|
||||||
getHistoryVideos,
|
|
||||||
getSuggestedVideos,
|
|
||||||
getRelatedVideos,
|
|
||||||
getRecentHistory
|
|
||||||
} from './actions';
|
|
||||||
import {
|
|
||||||
VideoData,
|
|
||||||
CATEGORY_MAP,
|
|
||||||
ALL_CATEGORY_SECTIONS,
|
|
||||||
addRegion,
|
|
||||||
getRandomModifier
|
|
||||||
} from './utils';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export default function Home() {
|
||||||
|
return (
|
||||||
const REGION_LABELS: Record<string, string> = {
|
<Suspense fallback={<LoadingSpinner fullScreen text="Loading videos..." />}>
|
||||||
VN: 'Vietnam',
|
<ClientHomePage />
|
||||||
US: 'United States',
|
</Suspense>
|
||||||
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<string>();
|
|
||||||
|
|
||||||
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<string>();
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div style={{ paddingTop: '12px' }}>
|
|
||||||
{/* Category Chips Scrollbar */}
|
|
||||||
<div style={{ display: 'flex', gap: '12px', padding: '0 12px', marginBottom: '16px', overflowX: 'auto', justifyContent: 'center' }} className="chips-container hide-scrollbox">
|
|
||||||
{categoriesList.map((cat) => {
|
|
||||||
const isActive = cat === currentCategory;
|
|
||||||
return (
|
|
||||||
<Link key={cat} href={cat === 'All' ? '/' : `/?category=${encodeURIComponent(cat)}`} style={{ textDecoration: 'none' }}>
|
|
||||||
<button
|
|
||||||
className={`chip ${isActive ? 'active' : ''}`}
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
transition: 'var(--yt-transition)',
|
|
||||||
backgroundColor: isActive ? 'var(--foreground)' : 'var(--yt-hover)',
|
|
||||||
color: isActive ? 'var(--background)' : 'var(--yt-text-primary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: '0 12px' }} className="main-container-mobile">
|
|
||||||
<InfiniteVideoGrid
|
|
||||||
initialVideos={gridVideos}
|
|
||||||
currentCategory={currentCategory}
|
|
||||||
regionLabel={regionLabel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
223
frontend/app/search/ClientSearchPage.tsx
Normal file
223
frontend/app/search/ClientSearchPage.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
{[...Array(12)].map((_, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{ display: 'flex', gap: '12px', padding: '0' }}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
<div style={{ width: '90%', height: '16px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
|
||||||
|
<div style={{ width: '60%', height: '12px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
|
||||||
|
<div style={{ width: '40%', height: '12px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientSearchPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const query = searchParams.get('q') || '';
|
||||||
|
const [videos, setVideos] = useState<VideoData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [searchPage, setSearchPage] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const observerTarget = useRef<HTMLDivElement>(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 (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: '0 24px 24px',
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||||
|
{/* Results Header */}
|
||||||
|
{query && !loading && (
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
{videos.length > 0 ? `${videos.length} results for "${query}"` : `No results for "${query}"`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<SearchSkeleton />
|
||||||
|
) : videos.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '80px 24px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
}}>
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style={{ marginBottom: '16px', opacity: 0.5 }}>
|
||||||
|
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 style={{ fontSize: '18px', marginBottom: '8px', color: 'var(--yt-text-primary)' }}>
|
||||||
|
No results found
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '14px' }}>Try different keywords or check your spelling</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
{videos.map((video) => (
|
||||||
|
<VideoCard key={video.id} video={video} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infinite scroll sentinel */}
|
||||||
|
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{loadingMore && <LoadingSpinner />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End of results */}
|
||||||
|
{!hasMore && videos.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '24px 0',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
End of results
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,188 +1,21 @@
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import ClientSearchPage from './ClientSearchPage';
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
interface VideoData {
|
export default function SearchPage() {
|
||||||
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<VideoData[]>;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchSkeleton() {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', maxWidth: '1096px', margin: '0 auto' }}>
|
<Suspense fallback={
|
||||||
{[1, 2, 3, 4].map(i => (
|
<div style={{
|
||||||
<div key={i} style={{ display: 'flex', gap: '16px' }} className={`fade-in-up stagger-${i}`}>
|
display: 'flex',
|
||||||
<div className="skeleton" style={{ width: '360px', minWidth: '360px', aspectRatio: '16/9', flexShrink: 0 }} />
|
justifyContent: 'center',
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px', paddingTop: '4px' }}>
|
alignItems: 'center',
|
||||||
<div className="skeleton skeleton-line" style={{ width: '90%', height: '18px' }} />
|
height: '100vh',
|
||||||
<div className="skeleton skeleton-line" style={{ width: '70%', height: '18px' }} />
|
backgroundColor: '#0f0f0f',
|
||||||
<div className="skeleton skeleton-line-short" style={{ marginTop: '8px' }} />
|
color: '#fff',
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
|
}}>
|
||||||
<div className="skeleton skeleton-avatar" style={{ width: '24px', height: '24px' }} />
|
Searching...
|
||||||
<div className="skeleton skeleton-line" style={{ width: '120px' }} />
|
|
||||||
</div>
|
|
||||||
<div className="skeleton skeleton-line" style={{ width: '80%', marginTop: '8px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function SearchResults({ query }: { query: string }) {
|
|
||||||
const videos = await fetchSearchResults(query);
|
|
||||||
|
|
||||||
if (videos.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="fade-in" style={{ padding: '48px 24px', color: 'var(--yt-text-secondary)', textAlign: 'center' }}>
|
|
||||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔍</div>
|
|
||||||
<div style={{ fontSize: '18px', fontWeight: 500, color: 'var(--yt-text-primary)', marginBottom: '8px' }}>
|
|
||||||
No results found
|
|
||||||
</div>
|
|
||||||
<div>Try different keywords or check your spelling</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
}>
|
||||||
}
|
<ClientSearchPage />
|
||||||
|
</Suspense>
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '1096px', margin: '0 auto' }} className="search-results-container">
|
|
||||||
{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 (
|
|
||||||
<Link href={`/watch?v=${v.id}`} key={v.id} style={{ display: 'flex', gap: '16px', textDecoration: 'none', color: 'inherit', maxWidth: '1096px', borderRadius: '12px', padding: '8px', margin: '-8px', transition: 'background-color 0.2s ease' }} className={`search-result-item search-result-hover fade-in-up ${staggerClass}`}>
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div style={{ position: 'relative', width: '360px', minWidth: '360px', aspectRatio: '16/9', flexShrink: 0, overflow: 'hidden', borderRadius: '8px' }} className="search-result-thumb-container">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={v.thumbnail}
|
|
||||||
alt={v.title}
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', backgroundColor: '#272727' }}
|
|
||||||
className="search-result-thumb"
|
|
||||||
onError={(e) => {
|
|
||||||
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 && (
|
|
||||||
<span className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
|
|
||||||
{v.duration}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Result Info */}
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', paddingTop: '0px' }} className="search-result-info">
|
|
||||||
<h3 style={{ fontSize: '18px', fontWeight: '400', lineHeight: '26px', margin: '0 0 4px 0', color: 'var(--yt-text-primary)' }} className="search-result-title">
|
|
||||||
{v.title}
|
|
||||||
</h3>
|
|
||||||
<div style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', marginBottom: '12px' }}>
|
|
||||||
{formatViews(v.view_count)} views • {relativeTime}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Channel block inline */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '24px', height: '24px', borderRadius: '50%', background: 'var(--yt-avatar-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '11px', color: '#fff', overflow: 'hidden', fontWeight: 600 }}>
|
|
||||||
{v.avatar_url ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={v.avatar_url}
|
|
||||||
alt=""
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
onError={(e) => {
|
|
||||||
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}
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>{v.uploader}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="truncate-2-lines" style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', lineHeight: '18px' }}>
|
|
||||||
{v.description || 'No description provided.'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const REGION_LABELS: Record<string, string> = {
|
|
||||||
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 (
|
|
||||||
<div className="fade-in" style={{ padding: '48px 24px', color: 'var(--yt-text-secondary)', textAlign: 'center' }}>
|
|
||||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔍</div>
|
|
||||||
<div style={{ fontSize: '18px', fontWeight: 500, color: 'var(--yt-text-primary)', marginBottom: '8px' }}>
|
|
||||||
Search KV-Tube
|
|
||||||
</div>
|
|
||||||
<div>Enter a search term above to find videos</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const regionCode = cookieStore.get('region')?.value || 'VN';
|
|
||||||
const regionLabel = REGION_LABELS[regionCode] || '';
|
|
||||||
const biasedQuery = regionLabel ? `${q} ${regionLabel}` : q;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '16px 24px 24px 24px' }} className="search-page-container">
|
|
||||||
<Suspense fallback={<SearchSkeleton />}>
|
|
||||||
<SearchResults query={biasedQuery} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
320
frontend/app/services/youtube.ts
Normal file
320
frontend/app/services/youtube.ts
Normal file
|
|
@ -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<string, string> = {}): Promise<any> {
|
||||||
|
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<YouTubeSearchResult[]> {
|
||||||
|
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<YouTubeVideo | null> {
|
||||||
|
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<YouTubeVideo[]> {
|
||||||
|
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<YouTubeChannel | null> {
|
||||||
|
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<YouTubeSearchResult[]> {
|
||||||
|
// 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<YouTubeComment[]> {
|
||||||
|
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<YouTubeVideo[]> {
|
||||||
|
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<YouTubeSearchResult[]> {
|
||||||
|
// 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<string[]> {
|
||||||
|
// 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();
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { IoHeart, IoHeartOutline, IoChatbubbleOutline, IoShareOutline, IoEllipsisHorizontal, IoMusicalNote, IoRefresh, IoPlay, IoVolumeMute, IoVolumeHigh } from 'react-icons/io5';
|
import { IoHeart, IoHeartOutline, IoChatbubbleOutline, IoShareOutline, IoEllipsisHorizontal, IoMusicalNote, IoRefresh, IoPlay, IoVolumeMute, IoVolumeHigh } from 'react-icons/io5';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -225,7 +226,7 @@ function ShortCard({ video, isActive }: { video: ShortVideo; isActive: boolean }
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={loadingOverlayStyle}>
|
<div style={loadingOverlayStyle}>
|
||||||
<div style={spinnerStyle}></div>
|
<LoadingSpinner color="white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && !useFallback && (
|
{error && !useFallback && (
|
||||||
|
|
@ -413,15 +414,6 @@ const openBtnStyle: React.CSSProperties = {
|
||||||
zIndex: 10,
|
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() {
|
export default function ShortsPage() {
|
||||||
const [shorts, setShorts] = useState<ShortVideo[]>([]);
|
const [shorts, setShorts] = useState<ShortVideo[]>([]);
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
@ -473,7 +465,7 @@ export default function ShortsPage() {
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div style={pageStyle}>
|
<div style={pageStyle}>
|
||||||
<div style={{ ...spinnerContainerStyle, width: '300px', height: '500px' }}>
|
<div style={{ ...spinnerContainerStyle, width: '300px', height: '500px' }}>
|
||||||
<div style={spinnerStyle}></div>
|
<LoadingSpinner color="white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -492,11 +484,10 @@ export default function ShortsPage() {
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={scrollContainerStyle}>
|
<div ref={containerRef} style={scrollContainerStyle}>
|
||||||
<style>{hideScrollbarCss}</style>
|
<style>{hideScrollbarCss}</style>
|
||||||
<style>{spinCss}</style>
|
|
||||||
{shorts.map((v, i) => <ShortCard key={v.id} video={v} isActive={i === activeIndex} />)}
|
{shorts.map((v, i) => <ShortCard key={v.id} video={v} isActive={i === activeIndex} />)}
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<div style={{ ...pageStyle, height: '100vh' }}>
|
<div style={{ ...pageStyle, height: '100vh' }}>
|
||||||
<div style={spinnerStyle}></div>
|
<LoadingSpinner color="white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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 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 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 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; }';
|
const hideScrollbarCss = 'div::-webkit-scrollbar { display: none; }';
|
||||||
|
|
|
||||||
178
frontend/app/storage.ts
Normal file
178
frontend/app/storage.ts
Normal file
|
|
@ -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<T>(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<T>(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<HistoryItem>(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<HistoryItem>(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<HistoryItem>(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<Subscription>(SUBSCRIPTIONS_KEY)
|
||||||
|
.sort((a, b) => b.subscribedAt - a.subscribedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(channel: { channelId: string; channelName: string; channelAvatar?: string }): void {
|
||||||
|
const subs = getFromStorage<Subscription>(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<Subscription>(SUBSCRIPTIONS_KEY);
|
||||||
|
const filtered = subs.filter(s => s.channelId !== channelId);
|
||||||
|
saveToStorage(SUBSCRIPTIONS_KEY, filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubscribed(channelId: string): boolean {
|
||||||
|
const subs = getFromStorage<Subscription>(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<SavedVideo>(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<SavedVideo>(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<SavedVideo>(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<SavedVideo>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
996
frontend/app/watch/ClientWatchPage.tsx
Normal file
996
frontend/app/watch/ClientWatchPage.tsx
Normal file
|
|
@ -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<string, { data: any; timestamp: number }>();
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: '12px 0' }}>
|
||||||
|
{/* Title */}
|
||||||
|
<h1 style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
}}>
|
||||||
|
{video.title || 'Untitled Video'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Channel Info & Actions Row */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '12px',
|
||||||
|
paddingBottom: '12px',
|
||||||
|
borderBottom: '1px solid var(--yt-border)',
|
||||||
|
}}>
|
||||||
|
{/* Channel - only show name, no avatar */}
|
||||||
|
<div style={{
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
{video.channelTitle || 'Unknown Channel'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons - Subscribe, Share, Save */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{/* Subscribe Button with Toggle State */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubscribe}
|
||||||
|
disabled={subscribing}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: subscribed ? 'var(--yt-hover)' : '#cc0000',
|
||||||
|
color: subscribed ? 'var(--yt-text-primary)' : '#fff',
|
||||||
|
border: subscribed ? '1px solid var(--yt-border)' : 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: subscribing ? 'wait' : 'pointer',
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: '13px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
opacity: subscribing ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subscribing ? (
|
||||||
|
'...'
|
||||||
|
) : subscribed ? (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||||
|
</svg>
|
||||||
|
Subscribed
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Subscribe'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Share Button */}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: video.title || 'Check out this video',
|
||||||
|
url: window.location.href,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (shareErr: any) {
|
||||||
|
if (shareErr.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
|
alert('Link copied to clipboard!');
|
||||||
|
} catch (err) {
|
||||||
|
alert('Could not share or copy link');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9.41 15.95L12 13.36l2.59 2.59L16 14.54l-2.59-2.59L16 9.36l-1.41-1.41L12 10.54 9.41 7.95 8 9.36l2.59 2.59L8 14.54z"/>
|
||||||
|
</svg>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Save Button with Toggle State */}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: isSaved ? 'var(--yt-blue)' : 'var(--yt-hover)',
|
||||||
|
color: isSaved ? '#fff' : 'var(--yt-text-primary)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSaved ? (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
Saved
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14 10H2v2h12v-2zm0-4H2v2h12V6zm4 8v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2 16h8v-2H2v2z"/>
|
||||||
|
</svg>
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description Box */}
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
marginTop: '12px',
|
||||||
|
}}>
|
||||||
|
{/* Views and Date */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: 'var(--yt-text-primary)'
|
||||||
|
}}>
|
||||||
|
<span>{formatViews(video.viewCount)}</span>
|
||||||
|
{video.publishedAt && formatDate(video.publishedAt) && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDate(video.publishedAt)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{hasDescription ? (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}>
|
||||||
|
{displayDescription}
|
||||||
|
{shouldTruncate && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--yt-blue)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '500',
|
||||||
|
padding: 0,
|
||||||
|
marginLeft: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? ' Show less' : ' ...more'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{video.tags && video.tags.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '12px' }}>
|
||||||
|
{video.tags.slice(0, 10).map((tag: string, i: number) => (
|
||||||
|
<span key={i} style={{
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '14px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--yt-blue)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mix Playlist Component
|
||||||
|
function MixPlaylist({ videos, currentIndex, onVideoSelect, title }: {
|
||||||
|
videos: VideoData[];
|
||||||
|
currentIndex: number;
|
||||||
|
onVideoSelect: (index: number) => void;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--yt-border)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '14px', fontWeight: '600', margin: 0, color: 'var(--yt-text-primary)' }}>
|
||||||
|
{title || 'Mix Playlist'}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', margin: '2px 0 0 0' }}>
|
||||||
|
{videos.length} videos • Auto-play is on
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video List */}
|
||||||
|
<div style={{ maxHeight: '360px', overflowY: 'auto' }}>
|
||||||
|
{videos.map((video, index) => (
|
||||||
|
<div
|
||||||
|
key={video.id}
|
||||||
|
onClick={() => 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 */}
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<img
|
||||||
|
src={video.thumbnail || `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`}
|
||||||
|
alt={video.title}
|
||||||
|
style={{
|
||||||
|
width: '100px',
|
||||||
|
height: '56px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/default.jpg`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '3px',
|
||||||
|
left: '3px',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '10px',
|
||||||
|
}}>
|
||||||
|
{index + 1}/{videos.length}
|
||||||
|
</div>
|
||||||
|
{index === currentIndex && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '6px',
|
||||||
|
}}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
|
||||||
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: index === currentIndex ? '600' : '500',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{video.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||||
|
{video.uploader}
|
||||||
|
</div>
|
||||||
|
{video.duration && (
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--yt-text-secondary)', marginTop: '1px' }}>
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment Section
|
||||||
|
function CommentSection({ videoId }: { videoId: string }) {
|
||||||
|
const [comments, setComments] = useState<any[]>([]);
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: '24px 0', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
Loading comments...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayedComments = showAll ? comments : comments.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 0', borderTop: '1px solid var(--yt-border)' }}>
|
||||||
|
<h2 style={{ fontSize: '16px', fontWeight: '600', marginBottom: '16px', color: 'var(--yt-text-primary)' }}>
|
||||||
|
{comments.length} Comments
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Sort dropdown */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '24px' }}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--yt-text-secondary)">
|
||||||
|
<path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)' }}>Sort by</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
|
{displayedComments.map((comment) => (
|
||||||
|
<div key={comment.id} style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
{comment.author_thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={comment.author_thumbnail}
|
||||||
|
alt={comment.author}
|
||||||
|
style={{ width: '40px', height: '40px', borderRadius: '50%', backgroundColor: 'var(--yt-hover)', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: '500', color: 'var(--yt-text-primary)' }}>
|
||||||
|
{comment.author}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
{comment.timestamp}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--yt-text-primary)', marginTop: '4px', lineHeight: '1.5' }}>
|
||||||
|
{comment.text}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '8px' }}>
|
||||||
|
<button style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/>
|
||||||
|
</svg>
|
||||||
|
{comment.likes}
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--yt-blue)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{comments.length > 5 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--yt-blue)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
padding: '8px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAll ? 'Show less' : `Show all ${comments.length} comments`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientWatchPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const videoId = searchParams.get('v');
|
||||||
|
const [videoInfo, setVideoInfo] = useState<any>(null);
|
||||||
|
const [relatedVideos, setRelatedVideos] = useState<VideoData[]>([]);
|
||||||
|
const [mixPlaylist, setMixPlaylist] = useState<VideoData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||||
|
const [activeTab, setActiveTab] = useState<'upnext' | 'mix'>('upnext');
|
||||||
|
const [apiError, setApiError] = useState<string | null>(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 <div style={{ padding: '2rem', color: 'var(--yt-text-primary)' }}>No video ID provided</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner fullScreen size="large" text="Loading video..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlaylist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
minHeight: '100vh',
|
||||||
|
}}>
|
||||||
|
<div className="watch-page-container" style={{
|
||||||
|
maxWidth: '1800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '24px',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 400px',
|
||||||
|
gap: '24px',
|
||||||
|
}}>
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="watch-main">
|
||||||
|
{/* Video Player */}
|
||||||
|
<div style={{ position: 'relative', width: '100%' }}>
|
||||||
|
<YouTubePlayer
|
||||||
|
videoId={videoId}
|
||||||
|
title={videoInfo?.title}
|
||||||
|
autoplay={true}
|
||||||
|
onVideoEnd={handleVideoEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Controls */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 0',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={currentIndex <= 0}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: currentIndex > 0 ? 'var(--yt-hover)' : 'transparent',
|
||||||
|
color: currentIndex > 0 ? 'var(--yt-text-primary)' : 'var(--yt-text-secondary)',
|
||||||
|
border: '1px solid var(--yt-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: currentIndex > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
opacity: currentIndex > 0 ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||||
|
</svg>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={currentIndex >= currentPlaylist.length - 1}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: currentIndex < currentPlaylist.length - 1 ? 'var(--yt-blue)' : 'var(--yt-hover)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: currentIndex < currentPlaylist.length - 1 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Info */}
|
||||||
|
<VideoInfo video={videoInfo} />
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<CommentSection videoId={videoId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="watch-sidebar" style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: '70px',
|
||||||
|
height: 'fit-content',
|
||||||
|
maxHeight: 'calc(100vh - 80px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
{/* Mix Playlist - Always on top */}
|
||||||
|
<MixPlaylist
|
||||||
|
videos={mixPlaylist}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
onVideoSelect={handleVideoSelect}
|
||||||
|
title={videoInfo?.title ? `Mix - ${videoInfo.title.split(' ').slice(0, 3).join(' ')}` : 'Mix Playlist'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* API Error Message */}
|
||||||
|
{apiError && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px',
|
||||||
|
backgroundColor: 'rgba(255, 0, 0, 0.1)',
|
||||||
|
border: '1px solid rgba(255, 0, 0, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{apiError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Up Next Section */}
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--yt-border)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{ fontSize: '14px', fontWeight: '600', margin: 0, color: 'var(--yt-text-primary)' }}>
|
||||||
|
Up Next
|
||||||
|
</h3>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
{relatedVideos.length} videos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||||
|
{relatedVideos.slice(0, 8).map((video, index) => (
|
||||||
|
<div
|
||||||
|
key={video.id}
|
||||||
|
onClick={() => 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';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<img
|
||||||
|
src={video.thumbnail || `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`}
|
||||||
|
alt={video.title}
|
||||||
|
style={{ width: '120px', height: '68px', objectFit: 'cover', borderRadius: '6px' }}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{video.duration && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '3px',
|
||||||
|
right: '3px',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '10px',
|
||||||
|
}}>
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{video.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||||
|
{video.uploader}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Responsive styles */}
|
||||||
|
<style jsx>{`
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.watch-page-container {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
.watch-sidebar {
|
||||||
|
position: relative !important;
|
||||||
|
top: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.watch-page-container {
|
||||||
|
padding: 12px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<CommentData[]>([]);
|
|
||||||
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 (
|
|
||||||
<div className="comments-section" style={{ marginTop: '24px', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
|
|
||||||
Comments are turned off or unavailable.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="comments-section" style={{ marginTop: '24px' }}>
|
|
||||||
<h3 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px' }}>Comments</h3>
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', gap: '16px', marginBottom: '20px' }}>
|
|
||||||
<div className="skeleton" style={{ width: '40px', height: '40px', borderRadius: '50%', flexShrink: 0 }} />
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div className="skeleton" style={{ height: '14px', width: '120px', marginBottom: '8px' }} />
|
|
||||||
<div className="skeleton" style={{ height: '14px', width: '80%', marginBottom: '4px' }} />
|
|
||||||
<div className="skeleton" style={{ height: '14px', width: '60%' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always render all comments; CSS handles mobile collapse via max-height
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="comments-section" style={{ marginTop: '24px' }}>
|
|
||||||
{/* Collapsed header for mobile - tappable to expand */}
|
|
||||||
{!isExpanded && comments.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="comments-collapsed-header"
|
|
||||||
onClick={() => 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'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--yt-text-primary)' }}>
|
|
||||||
Comments
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', marginLeft: '8px' }}>
|
|
||||||
{comments.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
{comments[0] && (
|
|
||||||
<span style={{ fontSize: '13px', color: 'var(--yt-text-secondary)', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{comments[0].text.slice(0, 60)}...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--yt-text-secondary)"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Desktop: always show full title. Mobile: hidden when collapsed */}
|
|
||||||
<h3 className="comments-full-header" style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px', color: 'var(--yt-text-primary)' }}>
|
|
||||||
{comments.length} Comments
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className={`comments-list ${isExpanded ? 'expanded' : ''}`}>
|
|
||||||
{comments.map((c) => (
|
|
||||||
<div key={c.id} style={{ display: 'flex', gap: '16px', marginBottom: '20px' }}>
|
|
||||||
<div style={{ position: 'relative', width: '40px', height: '40px', borderRadius: '50%', overflow: 'hidden', flexShrink: 0, backgroundColor: 'var(--yt-hover)' }}>
|
|
||||||
<Image
|
|
||||||
src={c.author_thumbnail || 'https://i.ytimg.com/img/channels/c_ip_m_default.jpg'}
|
|
||||||
alt={c.author}
|
|
||||||
fill
|
|
||||||
sizes="40px"
|
|
||||||
style={{ objectFit: 'cover' }}
|
|
||||||
onError={(e) => {
|
|
||||||
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
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, gap: '4px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
|
|
||||||
<span style={{ fontSize: '13px', fontWeight: 500, color: 'var(--yt-text-primary)' }}>
|
|
||||||
{c.author}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
|
|
||||||
{c.timestamp}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '20px',
|
|
||||||
color: 'var(--yt-text-primary)',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
overflowWrap: 'break-word',
|
|
||||||
wordBreak: 'break-word'
|
|
||||||
}}>
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: c.text }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{c.likes > 0 && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--yt-text-secondary)', fontSize: '12px' }}>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"></path></svg>
|
|
||||||
{c.likes}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show more / collapse toggle on mobile */}
|
|
||||||
{comments.length > 2 && (
|
|
||||||
<button
|
|
||||||
className="comments-toggle-btn"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
style={{
|
|
||||||
display: 'none', // Hidden on desktop, shown via CSS on mobile
|
|
||||||
width: '100%',
|
|
||||||
padding: '12px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: '1px solid var(--yt-border)',
|
|
||||||
borderRadius: '20px',
|
|
||||||
color: 'var(--yt-text-primary)',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginTop: '8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isExpanded ? 'Show less' : `Show ${comments.length - 2} more comments`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{comments.length === 0 && (
|
|
||||||
<div style={{ color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
|
|
||||||
No comments found.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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<HTMLImageElement>) {
|
|
||||||
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<HTMLAnchorElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeItemRef.current) {
|
|
||||||
activeItemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
|
||||||
}, [currentVideoId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'var(--yt-hover)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100%',
|
|
||||||
maxHeight: '500px',
|
|
||||||
marginBottom: '24px',
|
|
||||||
border: '1px solid var(--yt-border)'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
padding: '16px',
|
|
||||||
borderBottom: '1px solid var(--yt-border)',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.2)'
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: 'var(--yt-text-primary)' }}>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<div style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', marginTop: '4px' }}>
|
|
||||||
{currentIndex + 1} / {videos.length} videos
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
overflowY: 'auto',
|
|
||||||
flex: 1,
|
|
||||||
padding: '8px 0'
|
|
||||||
}}>
|
|
||||||
{videos.map((video, index) => {
|
|
||||||
const isActive = video.id === currentVideoId;
|
|
||||||
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={video.id}
|
|
||||||
href={`/watch?v=${video.id}&list=${listId}`}
|
|
||||||
ref={isActive ? activeItemRef : null}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '12px',
|
|
||||||
padding: '8px 16px',
|
|
||||||
textDecoration: 'none',
|
|
||||||
backgroundColor: isActive ? 'var(--yt-active)' : 'transparent',
|
|
||||||
alignItems: 'center',
|
|
||||||
transition: 'background-color 0.2s'
|
|
||||||
}}
|
|
||||||
className="playlist-item-hover"
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: '24px',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: 'var(--yt-text-secondary)',
|
|
||||||
textAlign: 'center',
|
|
||||||
flexShrink: 0
|
|
||||||
}}>
|
|
||||||
{isActive ? '▶' : index + 1}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
position: 'relative',
|
|
||||||
width: '100px',
|
|
||||||
aspectRatio: '16/9',
|
|
||||||
flexShrink: 0,
|
|
||||||
borderRadius: '8px',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
<Image
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={video.title}
|
|
||||||
fill
|
|
||||||
sizes="100px"
|
|
||||||
style={{ objectFit: 'cover' }}
|
|
||||||
onError={handleImageError}
|
|
||||||
/>
|
|
||||||
{video.duration && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '4px',
|
|
||||||
right: '4px',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '2px 4px',
|
|
||||||
fontSize: '10px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontWeight: 500
|
|
||||||
}}>
|
|
||||||
{video.duration}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, justifyContent: 'center' }}>
|
|
||||||
<h4 style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: isActive ? 600 : 400,
|
|
||||||
color: 'var(--yt-text-primary)',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
{video.title}
|
|
||||||
</h4>
|
|
||||||
<div style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', marginTop: '4px' }}>
|
|
||||||
{video.uploader}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<HTMLImageElement>) => {
|
|
||||||
const img = e.target as HTMLImageElement;
|
|
||||||
if (img.src !== DEFAULT_THUMBNAIL) {
|
|
||||||
img.src = DEFAULT_THUMBNAIL;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={video.id}
|
|
||||||
href={`/watch?v=${video.id}`}
|
|
||||||
className={`related-video-item fade-in-up ${staggerClass}`}
|
|
||||||
style={{ opacity: 1 }}
|
|
||||||
>
|
|
||||||
<div className="related-thumb-container">
|
|
||||||
<img
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={video.title}
|
|
||||||
className="related-thumb-img"
|
|
||||||
onError={handleImageError}
|
|
||||||
/>
|
|
||||||
{video.duration && (
|
|
||||||
<div className="duration-badge">
|
|
||||||
{video.duration}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="related-video-info">
|
|
||||||
<span className="related-video-title">{video.title}</span>
|
|
||||||
<span className="related-video-channel">{video.uploader}</span>
|
|
||||||
<span className="related-video-meta">{views} views</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RelatedVideos({ initialVideos, nextVideoId }: RelatedVideosProps) {
|
|
||||||
const [videos, setVideos] = useState<VideoData[]>(initialVideos);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setVideos(initialVideos);
|
|
||||||
}, [initialVideos]);
|
|
||||||
|
|
||||||
if (videos.length === 0) {
|
|
||||||
return <div style={{ padding: '1rem', color: '#888' }}>No related videos found.</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="watch-related-list">
|
|
||||||
<Link href={`/watch?v=${nextVideoId}`} className="related-video-item fade-in-up" style={{ opacity: 1 }}>
|
|
||||||
<div className="related-thumb-container">
|
|
||||||
<div className="next-up-overlay">
|
|
||||||
<span>UP NEXT</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
{videos.map((video, i) => (
|
|
||||||
<RelatedVideoItem key={video.id} video={video} index={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<div style={skeletonContainerStyle}>
|
|
||||||
<div style={skeletonCenterStyle}>
|
|
||||||
<div style={skeletonSpinnerStyle} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
|
||||||
const hlsRef = useRef<any>(null);
|
|
||||||
const audioHlsRef = useRef<any>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [useFallback, setUseFallback] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(false);
|
|
||||||
const [qualities, setQualities] = useState<QualityOption[]>([]);
|
|
||||||
const [currentQuality, setCurrentQuality] = useState<number>(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<string | undefined>();
|
|
||||||
const [nextListId, setNextListId] = useState<string | undefined>();
|
|
||||||
const [showBackgroundHint, setShowBackgroundHint] = useState(false);
|
|
||||||
const [wakeLock, setWakeLock] = useState<any>(null);
|
|
||||||
const [isPiPActive, setIsPiPActive] = useState(false);
|
|
||||||
const [autoPiPEnabled, setAutoPiPEnabled] = useState(true);
|
|
||||||
const [showPiPNotification, setShowPiPNotification] = useState(false);
|
|
||||||
const audioUrlRef = useRef<string>('');
|
|
||||||
|
|
||||||
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 <div style={noVideoStyle}>No video ID</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useFallback) {
|
|
||||||
return (
|
|
||||||
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => setShowControls(false)}>
|
|
||||||
<iframe
|
|
||||||
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1&playsinline=1`}
|
|
||||||
style={iframeStyle}
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; background-sync"
|
|
||||||
allowFullScreen
|
|
||||||
title={title || 'Video'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
|
|
||||||
{/* Show skeleton only during initial load, buffering overlay during playback */}
|
|
||||||
{isLoading && <PlayerSkeleton />}
|
|
||||||
|
|
||||||
{/* Show buffering overlay only when not in initial load state */}
|
|
||||||
{isBuffering && !isLoading && (
|
|
||||||
<div style={bufferingOverlayStyle}>
|
|
||||||
<div style={spinnerStyle} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
|
|
||||||
controls
|
|
||||||
playsInline
|
|
||||||
webkit-playsinline="true"
|
|
||||||
x5-playsinline="true"
|
|
||||||
x5-video-player-type="h5"
|
|
||||||
x5-video-player-fullscreen="true"
|
|
||||||
preload="auto"
|
|
||||||
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
|
||||||
onError={(e) => {
|
|
||||||
const video = e.target as HTMLVideoElement;
|
|
||||||
if (video.poster !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
|
|
||||||
video.poster = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<audio ref={audioRef} style={{ display: 'none' }} />
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div style={errorContainerStyle}>
|
|
||||||
<div style={errorStyle}>
|
|
||||||
<span style={{ marginBottom: '8px' }}>{error}</span>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button onClick={() => { setError(null); setUseFallback(false); window.location.reload(); }} style={retryBtnStyle}>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setUseFallback(true)} style={{ ...retryBtnStyle, background: '#333' }}>
|
|
||||||
Use YouTube Player
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showControls && !error && !isLoading && (
|
|
||||||
<>
|
|
||||||
<a href={`https://www.youtube.com/watch?v=${videoId}`} target="_blank" rel="noopener noreferrer" style={openBtnStyle}>
|
|
||||||
Open on YouTube ↗
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div style={controlsRowStyle}>
|
|
||||||
{/* Picture-in-Picture Button */}
|
|
||||||
{document.pictureInPictureEnabled && (
|
|
||||||
<button
|
|
||||||
onClick={togglePiP}
|
|
||||||
style={pipBtnStyle}
|
|
||||||
title={isPiPActive ? "Exit Picture-in-Picture" : "Picture-in-Picture"}
|
|
||||||
>
|
|
||||||
{isPiPActive ? '⏹' : '📺'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Auto PiP Toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => setAutoPiPEnabled(!autoPiPEnabled)}
|
|
||||||
style={{ ...pipBtnStyle, background: autoPiPEnabled ? 'rgba(255,0,0,0.8)' : 'rgba(0,0,0,0.8)' }}
|
|
||||||
title={autoPiPEnabled ? "Auto PiP: ON (click to disable)" : "Auto PiP: OFF (click to enable)"}
|
|
||||||
>
|
|
||||||
{autoPiPEnabled ? '🔄' : '⏸'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{qualities.length > 0 && (
|
|
||||||
<div style={qualityContainerStyle}>
|
|
||||||
<button onClick={() => setShowQualityMenu(!showQualityMenu)} style={qualityBtnStyle}>
|
|
||||||
{qualities.find(q => q.height === currentQuality)?.label || 'Auto'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showQualityMenu && (
|
|
||||||
<div style={qualityMenuStyle}>
|
|
||||||
{qualities.map((q) => (
|
|
||||||
<button
|
|
||||||
key={q.height}
|
|
||||||
onClick={() => changeQuality(q)}
|
|
||||||
style={{
|
|
||||||
...qualityItemStyle,
|
|
||||||
background: q.height === currentQuality ? 'rgba(255,0,0,0.3)' : 'transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{q.label}
|
|
||||||
{q.height === currentQuality && ' ✓'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showBackgroundHint && (
|
|
||||||
<div style={backgroundHintStyle}>
|
|
||||||
{hasSeparateAudio ? (
|
|
||||||
<span>🎵 Audio playing in background</span>
|
|
||||||
) : (
|
|
||||||
<span>⚠️ Background playback may pause on some browsers</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setUseFallback(true)}
|
|
||||||
style={backgroundHintBtnStyle}
|
|
||||||
>
|
|
||||||
Use YouTube Player for better background playback
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showPiPNotification && (
|
|
||||||
<div style={pipNotificationStyle}>
|
|
||||||
<span>📺 Picture-in-Picture activated</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setAutoPiPEnabled(!autoPiPEnabled)}
|
|
||||||
style={pipToggleBtnStyle}
|
|
||||||
>
|
|
||||||
{autoPiPEnabled ? 'Disable Auto PiP' : 'Enable Auto PiP'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const noVideoStyle: React.CSSProperties = { width: '100%', background: '#000', borderRadius: '12px', aspectRatio: '16/9', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' };
|
|
||||||
const containerStyle: React.CSSProperties = { width: '100%', background: '#000', borderRadius: '12px', overflow: 'hidden', aspectRatio: '16/9', position: 'relative' };
|
|
||||||
const videoStyle: React.CSSProperties = { width: '100%', height: '100%', background: '#000' };
|
|
||||||
const iframeStyle: React.CSSProperties = { width: '100%', height: '100%', border: 'none' };
|
|
||||||
const errorContainerStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.8)' };
|
|
||||||
const errorStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px', padding: '20px', background: 'rgba(30,30,30,0.95)', borderRadius: '12px', color: '#fff', maxWidth: '90%' };
|
|
||||||
const retryBtnStyle: React.CSSProperties = { padding: '8px 16px', background: '#ff0000', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px' };
|
|
||||||
const openBtnStyle: React.CSSProperties = { position: 'absolute', top: '10px', right: '10px', padding: '6px 12px', background: 'rgba(0,0,0,0.8)', color: '#fff', borderRadius: '4px', textDecoration: 'none', fontSize: '12px', zIndex: 10 };
|
|
||||||
const qualityContainerStyle: React.CSSProperties = { position: 'absolute', bottom: '50px', right: '10px', zIndex: 10 };
|
|
||||||
const qualityBtnStyle: React.CSSProperties = { padding: '6px 12px', background: 'rgba(0,0,0,0.8)', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', fontWeight: 500 };
|
|
||||||
const qualityMenuStyle: React.CSSProperties = { position: 'absolute', bottom: '100%', right: 0, marginBottom: '4px', background: 'rgba(0,0,0,0.95)', borderRadius: '8px', overflow: 'hidden', minWidth: '100px' };
|
|
||||||
const qualityItemStyle: React.CSSProperties = { display: 'block', width: '100%', padding: '8px 16px', color: '#fff', border: 'none', background: 'transparent', textAlign: 'left', cursor: 'pointer', fontSize: '13px', whiteSpace: 'nowrap' };
|
|
||||||
|
|
||||||
const skeletonContainerStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', zIndex: 5 };
|
|
||||||
const skeletonCenterStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'center' };
|
|
||||||
const skeletonSpinnerStyle: React.CSSProperties = { width: '48px', height: '48px', border: '4px solid rgba(255,255,255,0.2)', borderTopColor: '#fff', borderRadius: '50%', animation: 'spin 1s linear infinite' };
|
|
||||||
const bufferingOverlayStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', pointerEvents: 'none', zIndex: 5 };
|
|
||||||
const spinnerStyle: React.CSSProperties = { width: '40px', height: '40px', border: '3px solid rgba(255,255,255,0.2)', borderTopColor: '#fff', borderRadius: '50%', animation: 'spin 0.8s linear infinite' };
|
|
||||||
const backgroundHintStyle: React.CSSProperties = { position: 'absolute', bottom: '80px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(0,0,0,0.9)', color: '#fff', padding: '12px 16px', borderRadius: '8px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center', zIndex: 20, fontSize: '14px' };
|
|
||||||
const backgroundHintBtnStyle: React.CSSProperties = { padding: '6px 12px', background: '#ff0000', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' };
|
|
||||||
const controlsRowStyle: React.CSSProperties = { position: 'absolute', bottom: '10px', left: '10px', display: 'flex', gap: '8px', zIndex: 10 };
|
|
||||||
const pipBtnStyle: React.CSSProperties = { padding: '6px 10px', background: 'rgba(0,0,0,0.8)', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px' };
|
|
||||||
const pipNotificationStyle: React.CSSProperties = { position: 'absolute', top: '10px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(0,0,0,0.9)', color: '#fff', padding: '10px 16px', borderRadius: '8px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center', zIndex: 20, fontSize: '13px', animation: 'fadeIn 0.3s ease-out' };
|
|
||||||
const pipToggleBtnStyle: React.CSSProperties = { padding: '4px 8px', background: '#333', color: '#fff', border: '1px solid #555', borderRadius: '4px', cursor: 'pointer', fontSize: '11px' };
|
|
||||||
|
|
@ -1,431 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
||||||
import { PiShareFat } from 'react-icons/pi';
|
|
||||||
import { TfiDownload } from 'react-icons/tfi';
|
|
||||||
|
|
||||||
interface VideoFormat {
|
|
||||||
format_id: string;
|
|
||||||
format_note: string;
|
|
||||||
ext: string;
|
|
||||||
resolution: string;
|
|
||||||
filesize: number;
|
|
||||||
type: string;
|
|
||||||
has_audio?: boolean;
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
FFmpeg: any;
|
|
||||||
FFmpegWASM: any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQualityLabel(resolution: string): string {
|
|
||||||
const height = parseInt(resolution) || 0;
|
|
||||||
if (height >= 3840) return '4K UHD';
|
|
||||||
if (height >= 2560) return '2K QHD';
|
|
||||||
if (height >= 1920) return 'Full HD 1080p';
|
|
||||||
if (height >= 1280) return 'HD 720p';
|
|
||||||
if (height >= 854) return 'SD 480p';
|
|
||||||
if (height >= 640) return 'SD 360p';
|
|
||||||
if (height >= 426) return 'SD 240p';
|
|
||||||
if (height >= 256) return 'SD 144p';
|
|
||||||
return resolution || 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQualityBadge(height: number): { label: string; color: string } | null {
|
|
||||||
if (height >= 3840) return { label: '4K', color: '#ff0000' };
|
|
||||||
if (height >= 2560) return { label: '2K', color: '#ff6b00' };
|
|
||||||
if (height >= 1920) return { label: 'HD', color: '#00a0ff' };
|
|
||||||
if (height >= 1280) return { label: 'HD', color: '#00a0ff' };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WatchActions({ videoId }: { videoId: string }) {
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
const [showFormats, setShowFormats] = useState(false);
|
|
||||||
const [formats, setFormats] = useState<VideoFormat[]>([]);
|
|
||||||
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([]);
|
|
||||||
const [isLoadingFormats, setIsLoadingFormats] = useState(false);
|
|
||||||
const [downloadProgress, setDownloadProgress] = useState<string>('');
|
|
||||||
const [progressPercent, setProgressPercent] = useState(0);
|
|
||||||
const [ffmpegLoaded, setFfmpegLoaded] = useState(false);
|
|
||||||
const [ffmpegLoading, setFfmpegLoading] = useState(false);
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const ffmpegRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const loadFFmpeg = useCallback(async () => {
|
|
||||||
if (ffmpegLoaded || ffmpegLoading) return;
|
|
||||||
|
|
||||||
setFfmpegLoading(true);
|
|
||||||
setDownloadProgress('Loading video processor...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js';
|
|
||||||
script.async = true;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
|
|
||||||
const coreScript = document.createElement('script');
|
|
||||||
coreScript.src = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js';
|
|
||||||
coreScript.async = true;
|
|
||||||
document.head.appendChild(coreScript);
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const checkLoaded = () => {
|
|
||||||
if (window.FFmpeg && window.FFmpeg.FFmpeg) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
setTimeout(checkLoaded, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkLoaded();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { FFmpeg } = window.FFmpeg;
|
|
||||||
const ffmpeg = new FFmpeg();
|
|
||||||
|
|
||||||
await ffmpeg.load({
|
|
||||||
coreURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js',
|
|
||||||
wasmURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.wasm',
|
|
||||||
});
|
|
||||||
|
|
||||||
ffmpegRef.current = ffmpeg;
|
|
||||||
setFfmpegLoaded(true);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load FFmpeg:', e);
|
|
||||||
} finally {
|
|
||||||
setFfmpegLoading(false);
|
|
||||||
}
|
|
||||||
}, [ffmpegLoaded, ffmpegLoading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
||||||
setShowFormats(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleShare = () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const url = window.location.href;
|
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
|
||||||
alert('Link copied to clipboard!');
|
|
||||||
}).catch(() => {
|
|
||||||
alert('Failed to copy link');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchFormats = async () => {
|
|
||||||
if (showFormats) {
|
|
||||||
setShowFormats(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowFormats(true);
|
|
||||||
if (formats.length > 0) return;
|
|
||||||
|
|
||||||
setIsLoadingFormats(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/formats?v=${encodeURIComponent(videoId)}`);
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
const videoFormats = data.filter((f: VideoFormat) =>
|
|
||||||
(f.type === 'video' || f.type === 'both') &&
|
|
||||||
!f.format_note?.toLowerCase().includes('storyboard') &&
|
|
||||||
f.ext === 'mp4'
|
|
||||||
).sort((a: VideoFormat, b: VideoFormat) => {
|
|
||||||
const resA = parseInt(a.resolution) || 0;
|
|
||||||
const resB = parseInt(b.resolution) || 0;
|
|
||||||
return resB - resA;
|
|
||||||
});
|
|
||||||
|
|
||||||
const audioOnly = data.filter((f: VideoFormat) =>
|
|
||||||
f.type === 'audio' || (f.resolution === 'audio only')
|
|
||||||
).sort((a: VideoFormat, b: VideoFormat) => (b.filesize || 0) - (a.filesize || 0));
|
|
||||||
|
|
||||||
setFormats(videoFormats.length > 0 ? videoFormats : data.filter((f: VideoFormat) => f.ext === 'mp4').slice(0, 10));
|
|
||||||
setAudioFormats(audioOnly);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch formats:', e);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingFormats(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchFile = async (url: string, label: string): Promise<Uint8Array> => {
|
|
||||||
setDownloadProgress(`Downloading ${label}...`);
|
|
||||||
|
|
||||||
const tryDirect = async (): Promise<Response | null> => {
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
||||||
const response = await fetch(url, { signal: controller.signal, mode: 'cors' });
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return response.ok ? response : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryProxy = async (): Promise<Response> => {
|
|
||||||
return fetch(`/api/proxy-file?url=${encodeURIComponent(url)}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = await tryDirect();
|
|
||||||
if (!response) {
|
|
||||||
setDownloadProgress(`Connecting via proxy...`);
|
|
||||||
response = await tryProxy();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Failed to fetch ${label}`);
|
|
||||||
|
|
||||||
const contentLength = response.headers.get('content-length');
|
|
||||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
|
||||||
let loaded = 0;
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
if (!reader) throw new Error('No reader available');
|
|
||||||
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
chunks.push(value);
|
|
||||||
loaded += value.length;
|
|
||||||
if (total > 0) {
|
|
||||||
const percent = Math.round((loaded / total) * 100);
|
|
||||||
setProgressPercent(percent);
|
|
||||||
setDownloadProgress(`${label}: ${percent}%`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = new Uint8Array(loaded);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
result.set(chunk, offset);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadBlob = (blob: Blob, filename: string) => {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async (format?: VideoFormat) => {
|
|
||||||
setIsDownloading(true);
|
|
||||||
setShowFormats(false);
|
|
||||||
setProgressPercent(0);
|
|
||||||
setDownloadProgress('Preparing download...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Simple approach: use the backend's download-file endpoint
|
|
||||||
const downloadUrl = `/api/download-file?v=${encodeURIComponent(videoId)}${format ? `&f=${encodeURIComponent(format.format_id)}` : ''}`;
|
|
||||||
|
|
||||||
// Create a temporary anchor tag to trigger download
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = downloadUrl;
|
|
||||||
a.download = ''; // Let the server set filename via Content-Disposition
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
|
|
||||||
setDownloadProgress('Download started!');
|
|
||||||
setProgressPercent(100);
|
|
||||||
|
|
||||||
// Reset after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsDownloading(false);
|
|
||||||
setDownloadProgress('');
|
|
||||||
setProgressPercent(0);
|
|
||||||
}, 2000);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(e);
|
|
||||||
alert(e.message || 'Download failed. Please try again.');
|
|
||||||
setIsDownloading(false);
|
|
||||||
setDownloadProgress('');
|
|
||||||
setProgressPercent(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (!bytes || bytes <= 0) return '';
|
|
||||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
||||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', gap: '8px', position: 'relative', alignItems: 'center', flexShrink: 0 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleShare}
|
|
||||||
className="action-btn-hover"
|
|
||||||
style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--yt-hover)', border: 'none', borderRadius: '18px', padding: '0 16px', height: '36px', color: 'var(--yt-text-primary)', fontSize: '14px', fontWeight: '500', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<PiShareFat size={20} style={{ marginRight: '6px' }} />
|
|
||||||
Share
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
|
||||||
{showFormats && (
|
|
||||||
<div
|
|
||||||
className="download-backdrop"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
||||||
zIndex: 9999
|
|
||||||
}}
|
|
||||||
onClick={() => setShowFormats(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={fetchFormats}
|
|
||||||
disabled={isDownloading}
|
|
||||||
className="action-btn-hover"
|
|
||||||
style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--yt-hover)', border: 'none', borderRadius: '18px', padding: '0 16px', height: '36px', color: 'var(--yt-text-primary)', fontSize: '14px', fontWeight: '500', cursor: isDownloading ? 'wait' : 'pointer', opacity: isDownloading ? 0.7 : 1, minWidth: '120px' }}
|
|
||||||
>
|
|
||||||
<TfiDownload size={18} style={{ marginRight: '6px' }} />
|
|
||||||
{isDownloading ? (
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
{progressPercent > 0 ? `${progressPercent}%` : ''}
|
|
||||||
<span style={{ width: '12px', height: '12px', border: '2px solid currentColor', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block' }} />
|
|
||||||
</span>
|
|
||||||
) : 'Download'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showFormats && (
|
|
||||||
<div
|
|
||||||
className="download-dropdown"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
backgroundColor: 'var(--yt-background)',
|
|
||||||
borderRadius: '16px',
|
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
|
||||||
padding: '0',
|
|
||||||
zIndex: 10000,
|
|
||||||
width: 'calc(100% - 32px)',
|
|
||||||
maxWidth: '360px',
|
|
||||||
maxHeight: '70vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
border: '1px solid var(--yt-border)',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
padding: '16px',
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--yt-text-primary)',
|
|
||||||
borderBottom: '1px solid var(--yt-border)',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
|
||||||
<span>Select Quality</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFormats(false)}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'var(--yt-text-secondary)',
|
|
||||||
fontSize: '20px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px 8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoadingFormats ? (
|
|
||||||
<div style={{ padding: '20px 16px', textAlign: 'center', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
|
|
||||||
<span style={{ width: '20px', height: '20px', border: '2px solid var(--yt-text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block', marginRight: '8px' }} />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : formats.length === 0 ? (
|
|
||||||
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
|
|
||||||
No formats available
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
formats.map(f => {
|
|
||||||
const height = parseInt(f.resolution) || 0;
|
|
||||||
const badge = getQualityBadge(height);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={f.format_id}
|
|
||||||
onClick={() => handleDownload(f)}
|
|
||||||
className="format-item-hover"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
width: '100%',
|
|
||||||
padding: '12px 16px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: 'var(--yt-text-primary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
transition: 'background-color 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
||||||
{badge && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#fff',
|
|
||||||
background: badge.color,
|
|
||||||
padding: '3px 6px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
letterSpacing: '0.5px'
|
|
||||||
}}>
|
|
||||||
{badge.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span style={{ fontWeight: '500' }}>{getQualityLabel(f.resolution)}</span>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
|
|
||||||
{formatFileSize(f.filesize) || 'Unknown size'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isDownloading && downloadProgress && (
|
|
||||||
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', minWidth: '150px' }}>
|
|
||||||
{downloadProgress}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import InfiniteVideoGrid from '../components/InfiniteVideoGrid';
|
|
||||||
import { VideoData } from '../constants';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
videoId: string;
|
|
||||||
regionLabel: string;
|
|
||||||
initialMix: VideoData[]; // Initial 40:40:20 mix data for "All" tab
|
|
||||||
initialRelated: VideoData[]; // Initial related data for "Related" tab
|
|
||||||
initialSuggestions: VideoData[]; // Initial suggestions data for "For You" tab
|
|
||||||
}
|
|
||||||
|
|
||||||
const WATCH_TABS = ['All', 'Mix', 'Related', 'For You', 'Trending'];
|
|
||||||
|
|
||||||
export default function WatchFeed({ videoId, regionLabel, initialMix, initialRelated, initialSuggestions }: Props) {
|
|
||||||
const [activeTab, setActiveTab] = useState('All');
|
|
||||||
|
|
||||||
// Determine category id and initial videos based on active tab
|
|
||||||
let currentCategory = 'WatchAll';
|
|
||||||
let videos = initialMix;
|
|
||||||
|
|
||||||
if (activeTab === 'Related') {
|
|
||||||
currentCategory = 'WatchRelated';
|
|
||||||
videos = initialRelated;
|
|
||||||
} else if (activeTab === 'For You') {
|
|
||||||
currentCategory = 'WatchForYou';
|
|
||||||
videos = initialSuggestions;
|
|
||||||
} else if (activeTab === 'Trending') {
|
|
||||||
currentCategory = 'Trending';
|
|
||||||
// 'Trending' falls back to standard fetchMoreVideos logic which handles normal categories or we can handle it specifically.
|
|
||||||
// It's empty initially if missing, the infinite grid will load it.
|
|
||||||
videos = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: '24px' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '12px', padding: '0 0 16px 0', overflowX: 'auto' }} className="hide-scrollbox">
|
|
||||||
{WATCH_TABS.map((tab) => {
|
|
||||||
const isActive = tab === activeTab;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => {
|
|
||||||
if (tab === 'Mix') {
|
|
||||||
window.location.href = `/watch?v=${videoId}&list=RD${videoId}`;
|
|
||||||
} else {
|
|
||||||
setActiveTab(tab);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`chip ${isActive ? 'active' : ''}`}
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
backgroundColor: isActive ? 'var(--foreground)' : 'var(--yt-hover)',
|
|
||||||
color: isActive ? 'var(--background)' : 'var(--yt-text-primary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="watch-video-grid">
|
|
||||||
<InfiniteVideoGrid
|
|
||||||
key={currentCategory} // Force unmount/remount on tab change
|
|
||||||
initialVideos={videos}
|
|
||||||
currentCategory={currentCategory}
|
|
||||||
regionLabel={regionLabel}
|
|
||||||
contextVideoId={videoId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
239
frontend/app/watch/YouTubePlayer.tsx
Normal file
239
frontend/app/watch/YouTubePlayer.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
YT: any;
|
||||||
|
onYouTubeIframeAPIReady: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YouTubePlayerProps {
|
||||||
|
videoId: string;
|
||||||
|
title?: string;
|
||||||
|
autoplay?: boolean;
|
||||||
|
onVideoEnd?: () => void;
|
||||||
|
onVideoReady?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}>
|
||||||
|
<LoadingSpinner color="white" size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function YouTubePlayer({
|
||||||
|
videoId,
|
||||||
|
title,
|
||||||
|
autoplay = true,
|
||||||
|
onVideoEnd,
|
||||||
|
onVideoReady
|
||||||
|
}: YouTubePlayerProps) {
|
||||||
|
const playerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const playerInstanceRef = useRef<any>(null);
|
||||||
|
const [isApiReady, setIsApiReady] = useState(false);
|
||||||
|
const [isPlayerReady, setIsPlayerReady] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Load YouTube IFrame API
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.YT && window.YT.Player) {
|
||||||
|
setIsApiReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if script already exists
|
||||||
|
const existingScript = document.querySelector('script[src*="youtube.com/iframe_api"]');
|
||||||
|
if (existingScript) {
|
||||||
|
// Script exists, wait for it to load
|
||||||
|
const checkYT = setInterval(() => {
|
||||||
|
if (window.YT && window.YT.Player) {
|
||||||
|
setIsApiReady(true);
|
||||||
|
clearInterval(checkYT);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(checkYT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = document.createElement('script');
|
||||||
|
tag.src = 'https://www.youtube.com/iframe_api';
|
||||||
|
tag.async = true;
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
|
||||||
|
window.onYouTubeIframeAPIReady = () => {
|
||||||
|
console.log('YouTube IFrame API ready');
|
||||||
|
setIsApiReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Clean up
|
||||||
|
window.onYouTubeIframeAPIReady = () => {};
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize player when API is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isApiReady || !playerRef.current || !videoId) return;
|
||||||
|
|
||||||
|
// Destroy previous player instance if exists
|
||||||
|
if (playerInstanceRef.current) {
|
||||||
|
try {
|
||||||
|
playerInstanceRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error destroying player:', e);
|
||||||
|
}
|
||||||
|
playerInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const player = new window.YT.Player(playerRef.current, {
|
||||||
|
videoId: videoId,
|
||||||
|
playerVars: {
|
||||||
|
autoplay: autoplay ? 1 : 0,
|
||||||
|
controls: 1,
|
||||||
|
rel: 0,
|
||||||
|
modestbranding: 0,
|
||||||
|
playsinline: 1,
|
||||||
|
enablejsapi: 1,
|
||||||
|
origin: window.location.origin,
|
||||||
|
widget_referrer: window.location.href,
|
||||||
|
iv_load_policy: 3,
|
||||||
|
fs: 0,
|
||||||
|
disablekb: 0,
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
onReady: (event: any) => {
|
||||||
|
console.log('YouTube Player ready for video:', videoId);
|
||||||
|
setIsPlayerReady(true);
|
||||||
|
if (onVideoReady) onVideoReady();
|
||||||
|
|
||||||
|
// Auto-play if enabled
|
||||||
|
if (autoplay) {
|
||||||
|
try {
|
||||||
|
event.target.playVideo();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Autoplay prevented:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStateChange: (event: any) => {
|
||||||
|
// Video ended
|
||||||
|
if (event.data === window.YT.PlayerState.ENDED) {
|
||||||
|
if (onVideoEnd) {
|
||||||
|
onVideoEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (event: any) => {
|
||||||
|
console.error('YouTube Player Error:', event.data);
|
||||||
|
setError(`Failed to load video (Error ${event.data})`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
playerInstanceRef.current = player;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create YouTube player:', error);
|
||||||
|
setError('Failed to initialize video player');
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playerInstanceRef.current) {
|
||||||
|
try {
|
||||||
|
playerInstanceRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error cleaning up player:', e);
|
||||||
|
}
|
||||||
|
playerInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isApiReady, videoId, autoplay]);
|
||||||
|
|
||||||
|
// Handle video end
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlayerReady || !onVideoEnd) return;
|
||||||
|
|
||||||
|
const handleVideoEnd = () => {
|
||||||
|
onVideoEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The onStateChange event handler already handles this
|
||||||
|
}, [isPlayerReady, onVideoEnd]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#fff',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
<div>{error}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`https://www.youtube.com/watch?v=${videoId}`, '_blank')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#ff0000',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Watch on YouTube
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', backgroundColor: '#000', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
|
{!isPlayerReady && !error && <PlayerSkeleton />}
|
||||||
|
<div
|
||||||
|
ref={playerRef}
|
||||||
|
id={`youtube-player-${videoId}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to play a video
|
||||||
|
export function playVideo(videoId: string) {
|
||||||
|
if (window.YT && window.YT.Player) {
|
||||||
|
// Could create a new player instance or use existing one
|
||||||
|
console.log('Playing video:', videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to pause video
|
||||||
|
export function pauseVideo() {
|
||||||
|
// Would need to reference player instance
|
||||||
|
}
|
||||||
|
|
@ -1,272 +1,11 @@
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import VideoPlayer from './VideoPlayer';
|
import ClientWatchPage from './ClientWatchPage';
|
||||||
import Link from 'next/link';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
import WatchActions from './WatchActions';
|
|
||||||
import SubscribeButton from '../components/SubscribeButton';
|
|
||||||
import NextVideoClient from './NextVideoClient';
|
|
||||||
import WatchFeed from './WatchFeed';
|
|
||||||
import PlaylistPanel from './PlaylistPanel';
|
|
||||||
import Comments from './Comments';
|
|
||||||
import { API_BASE, VideoData } from '../constants';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
import { getRelatedVideos, getSuggestedVideos, getSearchVideos } from '../actions';
|
|
||||||
import { addRegion, getRandomModifier } from '../utils';
|
|
||||||
|
|
||||||
const REGION_LABELS: Record<string, string> = {
|
|
||||||
VN: 'Vietnam',
|
|
||||||
US: 'United States',
|
|
||||||
JP: 'Japan',
|
|
||||||
KR: 'South Korea',
|
|
||||||
IN: 'India',
|
|
||||||
GB: 'United Kingdom',
|
|
||||||
GLOBAL: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface VideoInfo {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
uploader: string;
|
|
||||||
channel_id: string;
|
|
||||||
view_count: number;
|
|
||||||
thumbnail?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getVideoInfo(id: string): Promise<VideoInfo | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/api/get_stream_info?v=${id}`, { cache: 'no-store' });
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = await res.json();
|
|
||||||
return {
|
|
||||||
title: data.title || `Video ${id}`,
|
|
||||||
description: data.description || '',
|
|
||||||
uploader: data.uploader || 'Unknown',
|
|
||||||
channel_id: data.channel_id || '',
|
|
||||||
view_count: data.view_count || 0,
|
|
||||||
thumbnail: data.thumbnail || `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
|
||||||
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
|
||||||
return num.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function WatchPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
|
||||||
}) {
|
|
||||||
const awaitParams = await searchParams;
|
|
||||||
const v = awaitParams.v as string;
|
|
||||||
const list = awaitParams.list as string;
|
|
||||||
const isMix = list?.startsWith('RD');
|
|
||||||
|
|
||||||
if (!v) {
|
|
||||||
return <div style={{ padding: '2rem' }}>No video ID provided</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = await getVideoInfo(v);
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const regionCode = cookieStore.get('region')?.value || 'VN';
|
|
||||||
const regionLabel = REGION_LABELS[regionCode] || '';
|
|
||||||
const randomMod = getRandomModifier();
|
|
||||||
|
|
||||||
// Fetch initial mix
|
|
||||||
const promises = [
|
|
||||||
getSuggestedVideos(12),
|
|
||||||
getRelatedVideos(v, 12),
|
|
||||||
getSearchVideos(addRegion("trending", regionLabel) + ' ' + randomMod, 6)
|
|
||||||
];
|
|
||||||
|
|
||||||
const [suggestedRes, relatedRes, trendingRes] = await Promise.all(promises);
|
|
||||||
|
|
||||||
const interleavedList: VideoData[] = [];
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
|
|
||||||
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 vid = suggestedRes[sIdx++];
|
|
||||||
if (!seenIds.has(vid.id) && vid.id !== v) { interleavedList.push(vid); seenIds.add(vid.id); }
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 2 && rIdx < relatedRes.length; i++) {
|
|
||||||
const vid = relatedRes[rIdx++];
|
|
||||||
if (!seenIds.has(vid.id) && vid.id !== v) { interleavedList.push(vid); seenIds.add(vid.id); }
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 1 && tIdx < trendingRes.length; i++) {
|
|
||||||
const vid = trendingRes[tIdx++];
|
|
||||||
if (!seenIds.has(vid.id) && vid.id !== v) { interleavedList.push(vid); seenIds.add(vid.id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let initialMix = interleavedList;
|
|
||||||
const initialRelated = relatedRes.filter(vid => vid.id !== v);
|
|
||||||
const initialSuggestions = suggestedRes.filter(vid => vid.id !== v);
|
|
||||||
|
|
||||||
// If not currently inside a mix, inject a Mix Card at the start
|
|
||||||
if (!isMix && info) {
|
|
||||||
const mixCard: VideoData = {
|
|
||||||
id: v,
|
|
||||||
title: `Mix - ${info.uploader || 'Auto-generated'}`,
|
|
||||||
uploader: info.uploader || 'KV-Tube',
|
|
||||||
thumbnail: info.thumbnail || `https://i.ytimg.com/vi/${v}/maxresdefault.jpg`,
|
|
||||||
view_count: 0,
|
|
||||||
duration: '50+',
|
|
||||||
list_id: `RD${v}`,
|
|
||||||
is_mix: true
|
|
||||||
};
|
|
||||||
initialMix.unshift(mixCard);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always build a Mix playlist
|
|
||||||
let playlistVideos: VideoData[] = [];
|
|
||||||
const mixBaseId = isMix ? list.replace('RD', '') : v;
|
|
||||||
const mixListId = isMix ? list : `RD${v}`;
|
|
||||||
{
|
|
||||||
const baseInfo = isMix ? await getVideoInfo(mixBaseId) : info;
|
|
||||||
|
|
||||||
// Seed the playlist with the base video
|
|
||||||
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
|
|
||||||
if (baseInfo) {
|
|
||||||
playlistVideos.push({
|
|
||||||
id: mixBaseId,
|
|
||||||
title: baseInfo.title,
|
|
||||||
uploader: baseInfo.uploader,
|
|
||||||
thumbnail: baseInfo.thumbnail || `https://i.ytimg.com/vi/${mixBaseId}/hqdefault.jpg` || DEFAULT_THUMBNAIL,
|
|
||||||
view_count: baseInfo.view_count,
|
|
||||||
duration: ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-source search to build a rich playlist (15-25 videos)
|
|
||||||
const uploaderName = baseInfo?.uploader || '';
|
|
||||||
const videoTitle = baseInfo?.title || '';
|
|
||||||
const titleKeywords = videoTitle.split(/[\s\-|]+/).filter((w: string) => w.length > 2).slice(0, 4).join(' ');
|
|
||||||
|
|
||||||
const mixPromises = [
|
|
||||||
uploaderName ? getSearchVideos(uploaderName, 20) : Promise.resolve([]),
|
|
||||||
titleKeywords ? getSearchVideos(titleKeywords, 20) : Promise.resolve([]),
|
|
||||||
getRelatedVideos(mixBaseId, 20),
|
|
||||||
];
|
|
||||||
|
|
||||||
const [byUploader, byTitle, byRelated] = await Promise.all(mixPromises);
|
|
||||||
const seenMixIds = new Set(playlistVideos.map(p => p.id));
|
|
||||||
|
|
||||||
const sources = [byUploader, byTitle, byRelated];
|
|
||||||
let added = 0;
|
|
||||||
const maxPlaylist = 50;
|
|
||||||
|
|
||||||
let idx = [0, 0, 0];
|
|
||||||
while (added < maxPlaylist) {
|
|
||||||
let anyAdded = false;
|
|
||||||
for (let s = 0; s < sources.length; s++) {
|
|
||||||
while (idx[s] < sources[s].length && added < maxPlaylist) {
|
|
||||||
const vid = sources[s][idx[s]++];
|
|
||||||
if (!seenMixIds.has(vid.id)) {
|
|
||||||
seenMixIds.add(vid.id);
|
|
||||||
playlistVideos.push({
|
|
||||||
...vid,
|
|
||||||
thumbnail: vid.thumbnail || `https://i.ytimg.com/vi/${vid.id}/hqdefault.jpg` || DEFAULT_THUMBNAIL
|
|
||||||
});
|
|
||||||
added++;
|
|
||||||
anyAdded = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anyAdded) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the next video
|
|
||||||
let nextVideoId = '';
|
|
||||||
let nextListId: string | undefined = undefined;
|
|
||||||
|
|
||||||
if (playlistVideos.length > 0) {
|
|
||||||
const currentIndex = playlistVideos.findIndex(p => p.id === v);
|
|
||||||
if (currentIndex >= 0 && currentIndex < playlistVideos.length - 1) {
|
|
||||||
nextVideoId = playlistVideos[currentIndex + 1].id;
|
|
||||||
nextListId = mixListId;
|
|
||||||
} else {
|
|
||||||
nextVideoId = initialMix.length > 0 && initialMix[0].is_mix ? (initialMix[1]?.id || '') : (initialMix[0]?.id || '');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const firstRealVideo = initialMix.find(vid => !vid.is_mix);
|
|
||||||
nextVideoId = firstRealVideo ? firstRealVideo.id : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export default function WatchPage() {
|
||||||
return (
|
return (
|
||||||
<div className="watch-container fade-in">
|
<Suspense fallback={<LoadingSpinner fullScreen text="Loading video..." />}>
|
||||||
{nextVideoId && <NextVideoClient videoId={nextVideoId} listId={nextListId} />}
|
<ClientWatchPage />
|
||||||
<div className="watch-primary">
|
</Suspense>
|
||||||
<div className="watch-player-wrapper">
|
|
||||||
<VideoPlayer
|
|
||||||
videoId={v}
|
|
||||||
title={info?.title}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="watch-title">
|
|
||||||
{info?.title || `Video ${v}`}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{info && (
|
|
||||||
<div className="watch-meta-row">
|
|
||||||
<div className="watch-channel-info">
|
|
||||||
<Link href={info.channel_id ? `/channel/${info.channel_id}` : '#'} className="watch-channel-link">
|
|
||||||
<div className="watch-channel-text">
|
|
||||||
<span className="watch-channel-name">{info.uploader}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<SubscribeButton channelId={info.channel_id} channelName={info.uploader} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="watch-actions-row">
|
|
||||||
<WatchActions videoId={v} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info && (
|
|
||||||
<div className="watch-description-box">
|
|
||||||
<div className="watch-description-stats">
|
|
||||||
{formatNumber(info.view_count)} views
|
|
||||||
</div>
|
|
||||||
<div className="watch-description-text">
|
|
||||||
{info.description || 'No description available.'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comments as a separate flex child for responsive reordering */}
|
|
||||||
<div className="comments-section">
|
|
||||||
<Comments videoId={v} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="watch-secondary">
|
|
||||||
{playlistVideos.length > 0 && (
|
|
||||||
<PlaylistPanel
|
|
||||||
videos={playlistVideos}
|
|
||||||
currentVideoId={v}
|
|
||||||
listId={mixListId}
|
|
||||||
title={`Mix - ${playlistVideos[0]?.uploader || 'YouTube'}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<WatchFeed
|
|
||||||
videoId={v}
|
|
||||||
regionLabel={regionLabel}
|
|
||||||
initialMix={initialMix}
|
|
||||||
initialRelated={initialRelated}
|
|
||||||
initialSuggestions={initialSuggestions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
1921
frontend/frontend.log
Normal file
1921
frontend/frontend.log
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,18 +9,12 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clappr/core": "^0.13.2",
|
|
||||||
"@clappr/player": "^0.11.16",
|
|
||||||
"@fontsource/roboto": "^5.2.9",
|
"@fontsource/roboto": "^5.2.9",
|
||||||
"@vidstack/react": "^1.12.13",
|
|
||||||
"artplayer": "^5.3.0",
|
|
||||||
"clappr": "^0.3.13",
|
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0"
|
||||||
"vidstack": "^1.12.13"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
console.log(document.querySelector(".yt-main-content").style.marginLeft); console.log(window.getComputedStyle(document.querySelector(".yt-main-content")).marginLeft);
|
|
||||||
File diff suppressed because one or more lines are too long
36
test-api.js
Normal file
36
test-api.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Test YouTube API key
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const API_KEY = 'AIzaSyCn5pHLCpvmaIH5sG2HL_JBwRgfp36oq3g';
|
||||||
|
|
||||||
|
function testAPI() {
|
||||||
|
const url = `https://www.googleapis.com/youtube/v3/search?key=${API_KEY}&part=snippet&q=music&type=video&maxResults=1`;
|
||||||
|
|
||||||
|
https.get(url, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('Status Code:', res.statusCode);
|
||||||
|
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
const response = JSON.parse(data);
|
||||||
|
console.log('✅ API Key is working!');
|
||||||
|
console.log('Items found:', response.items?.length || 0);
|
||||||
|
if (response.items?.length > 0) {
|
||||||
|
console.log('Sample result:', response.items[0].snippet.title);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ API Key failed with status:', res.statusCode);
|
||||||
|
console.log('Response:', data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', (err) => {
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testAPI();
|
||||||
Loading…
Reference in a new issue