Compare commits
No commits in common. "e0f9fe68423e18b203c16fc050322f973f2e8170" and "799a3ffb152b7abc94e1c07d6461876650d81307" have entirely different histories.
e0f9fe6842
...
799a3ffb15
58 changed files with 3395 additions and 5453 deletions
21
.dockerignore.bak
Normal file
21
.dockerignore.bak
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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
|
||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
|
@ -5,13 +5,6 @@ on:
|
|||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
api_url:
|
||||
description: 'API URL'
|
||||
required: false
|
||||
default: 'http://ut.khoavo.myds.me:8981/api'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
|
@ -110,5 +103,3 @@ jobs:
|
|||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
NEXT_PUBLIC_API_URL=${{ github.event.inputs.api_url || 'http://ut.khoavo.myds.me:8981/api' }}
|
||||
|
|
|
|||
46
.github/workflows/docker-publish.yml
vendored
46
.github/workflows/docker-publish.yml
vendored
|
|
@ -4,13 +4,6 @@ on:
|
|||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
api_url:
|
||||
description: 'API URL'
|
||||
required: false
|
||||
default: 'http://ut.khoavo.myds.me:8981/api'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -23,6 +16,9 @@ jobs:
|
|||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
@ -33,24 +29,42 @@ jobs:
|
|||
username: ${{ secrets.FORGEJO_USERNAME }}
|
||||
password: ${{ secrets.FORGEJO_PASSWORD }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
- name: Extract metadata (backend)
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.khoavo.myds.me/vndangkhoa/kv-tube
|
||||
images: git.khoavo.myds.me/${{ github.repository }}-backend
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
|
||||
|
||||
- name: Build and push
|
||||
- name: Build and push (backend)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (frontend)
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.khoavo.myds.me/${{ github.repository }}-frontend
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
|
||||
|
||||
- name: Build and push (frontend)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
NEXT_PUBLIC_API_URL=${{ github.event.inputs.api_url || 'http://ut.khoavo.myds.me:8981/api' }}
|
||||
|
|
|
|||
8
CUsersAdminDocumentskv-tubepage.html
Normal file
8
CUsersAdminDocumentskv-tubepage.html
Normal file
File diff suppressed because one or more lines are too long
1
CUsersAdminDocumentskv-tubetemp.js
Normal file
1
CUsersAdminDocumentskv-tubetemp.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
cat: can't open '/app/frontend/.next/required-server-files.js': No such file or directory
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
# ---- Backend Builder ----
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
ENV GOTOOLCHAIN=local
|
||||
ENV GOPROXY=https://proxy.golang.org,direct
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git gcc musl-dev
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN (echo "module kvtube-go"; echo ""; echo "go 1.24.0"; tail -n +4 go.mod) > go.mod.new && mv go.mod.new go.mod && go mod tidy
|
||||
RUN go mod download
|
||||
COPY backend/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o kv-tube .
|
||||
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o kv-tube .
|
||||
|
||||
# ---- Frontend Builder ----
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
|
|
@ -63,8 +62,7 @@ ENV NODE_ENV=production
|
|||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV KVTUBE_DATA_DIR=/app/data
|
||||
ENV GIN_MODE=release
|
||||
ARG NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ENV NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
|
||||
|
||||
EXPOSE 3000 8080
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ We recommend using **Container Manager** (DSM 7.2+) or **Docker** (DSM 6/7.1) fo
|
|||
|
||||
### 1. Prerequisites
|
||||
- **Container Manager** or **Docker** package installed from Package Center.
|
||||
- Ensure ports `5011` (frontend) and `8981` (backend API) are available on your NAS.
|
||||
- Ensure ports `5011` (frontend) and `8080` (backend API) are available on your NAS.
|
||||
- Create a folder named `kv-tube` in your `docker` shared folder (e.g., `/volume1/docker/kv-tube`).
|
||||
|
||||
### 2. Using Container Manager (Recommended)
|
||||
|
|
@ -75,7 +75,7 @@ services:
|
|||
restart: unless-stopped
|
||||
ports:
|
||||
- "5011:3000"
|
||||
- "8981:8080"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
|
|
@ -90,7 +90,7 @@ services:
|
|||
### 3. Accessing the App
|
||||
The application will be accessible at:
|
||||
- **Frontend**: `http://<your-nas-ip>:5011`
|
||||
- **Backend API**: `http://<your-nas-ip>:8981`
|
||||
- **Backend API**: `http://<your-nas-ip>:8080`
|
||||
- **Mobile Users**: Add to Home Screen via Safari for the full PWA experience with background playback.
|
||||
|
||||
### 4. Volume Permissions (If Needed)
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
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-mac
Normal file
BIN
backend/kv-tube-mac
Normal file
Binary file not shown.
BIN
backend/kv-tube → backend/kv-tube-new
Executable file → Normal file
BIN
backend/kv-tube → backend/kv-tube-new
Executable file → Normal file
Binary file not shown.
|
|
@ -1,11 +1,17 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kvtube-go/services"
|
||||
|
||||
|
|
@ -41,6 +47,57 @@ func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// isAllowedDomain checks if the URL belongs to allowed domains (YouTube/Google)
|
||||
func isAllowedDomain(targetURL string) error {
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Allowed domains for video proxy
|
||||
allowedDomains := []string{
|
||||
".youtube.com",
|
||||
".googlevideo.com",
|
||||
".ytimg.com",
|
||||
".google.com",
|
||||
".gstatic.com",
|
||||
}
|
||||
|
||||
host := strings.ToLower(parsedURL.Hostname())
|
||||
|
||||
// Check if host matches any allowed domain
|
||||
for _, domain := range allowedDomains {
|
||||
if strings.HasSuffix(host, domain) || host == strings.TrimPrefix(domain, ".") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("domain %s not allowed", host)
|
||||
}
|
||||
|
||||
// validateSearchQuery ensures search query contains only safe characters
|
||||
func validateSearchQuery(query string) error {
|
||||
// Allow alphanumeric, spaces, hyphens, underscores, dots, commas, exclamation marks
|
||||
safePattern := regexp.MustCompile(`^[a-zA-Z0-9\s\-_.,!]+$`)
|
||||
if !safePattern.MatchString(query) {
|
||||
return fmt.Errorf("search query contains invalid characters")
|
||||
}
|
||||
if len(query) > 200 {
|
||||
return fmt.Errorf("search query too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Global HTTP client with connection pooling and timeouts
|
||||
var httpClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
|
|
@ -60,26 +117,26 @@ func SetupRouter() *gin.Engine {
|
|||
c.Next()
|
||||
})
|
||||
|
||||
// API Routes - Using yt-dlp for video operations
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// Health check
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// Video endpoints
|
||||
// API Routes
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.GET("/search", handleSearch)
|
||||
api.GET("/trending", handleTrending)
|
||||
api.GET("/video/:id", handleGetVideoInfo)
|
||||
api.GET("/video/:id/qualities", handleGetQualities)
|
||||
api.GET("/video/:id/related", handleRelatedVideos)
|
||||
api.GET("/video/:id/comments", handleComments)
|
||||
api.GET("/video/:id/download", handleDownload)
|
||||
|
||||
// Channel endpoints
|
||||
api.GET("/channel/info", handleChannelInfo)
|
||||
api.GET("/get_stream_info", handleGetStreamInfo)
|
||||
api.GET("/download", handleDownload)
|
||||
api.GET("/download-file", handleDownloadFile)
|
||||
api.GET("/transcript", handleTranscript)
|
||||
api.GET("/comments", handleComments)
|
||||
api.GET("/channel/videos", handleChannelVideos)
|
||||
api.GET("/channel/info", handleChannelInfo)
|
||||
api.GET("/related", handleRelatedVideos)
|
||||
api.GET("/formats", handleGetFormats)
|
||||
api.GET("/qualities", handleGetQualities)
|
||||
api.GET("/stream", handleGetStreamByQuality)
|
||||
|
||||
// History routes
|
||||
api.POST("/history", handlePostHistory)
|
||||
|
|
@ -93,10 +150,11 @@ func SetupRouter() *gin.Engine {
|
|||
api.GET("/subscriptions", handleGetSubscriptions)
|
||||
}
|
||||
|
||||
r.GET("/video_proxy", handleVideoProxy)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Video search endpoint
|
||||
func handleSearch(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
|
|
@ -104,15 +162,21 @@ func handleSearch(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
limitStr := c.Query("limit")
|
||||
// Validate search query for security
|
||||
if err := validateSearchQuery(query); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 20
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||
limit = l
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
results, err := services.SearchVideos(query, limit)
|
||||
if err != nil {
|
||||
log.Printf("Search error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"})
|
||||
return
|
||||
}
|
||||
|
|
@ -120,189 +184,489 @@ func handleSearch(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// Trending videos endpoint
|
||||
func handleTrending(c *gin.Context) {
|
||||
limitStr := c.Query("limit")
|
||||
limit := 20
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
// Use popular music search as trending
|
||||
results, err := services.SearchVideos("popular music trending", limit)
|
||||
if err != nil {
|
||||
log.Printf("Trending error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trending videos"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, results)
|
||||
// Basic mock implementation for now
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []gin.H{
|
||||
{
|
||||
"id": "trending",
|
||||
"title": "Currently Trending",
|
||||
"icon": "fire",
|
||||
"videos": []gin.H{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Get video info
|
||||
func handleGetVideoInfo(c *gin.Context) {
|
||||
videoID := c.Param("id")
|
||||
func handleGetStreamInfo(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
video, err := services.GetVideoInfo(videoID)
|
||||
info, qualities, audioURL, err := services.GetFullStreamData(videoID)
|
||||
if err != nil {
|
||||
log.Printf("GetVideoInfo error: %v", err)
|
||||
log.Printf("GetFullStreamData Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, video)
|
||||
// Build quality options for frontend
|
||||
var qualityOptions []gin.H
|
||||
bestURL := info.StreamURL
|
||||
bestHeight := 0
|
||||
|
||||
for _, q := range qualities {
|
||||
proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL)
|
||||
audioProxyURL := ""
|
||||
if q.AudioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL)
|
||||
}
|
||||
qualityOptions = append(qualityOptions, gin.H{
|
||||
"label": q.Label,
|
||||
"height": q.Height,
|
||||
"url": proxyURL,
|
||||
"audio_url": audioProxyURL,
|
||||
"is_hls": q.IsHLS,
|
||||
"has_audio": q.HasAudio,
|
||||
})
|
||||
if q.Height > bestHeight {
|
||||
bestHeight = q.Height
|
||||
bestURL = q.URL
|
||||
}
|
||||
}
|
||||
|
||||
// If we found qualities, use the best one
|
||||
streamURL := info.StreamURL
|
||||
if bestURL != "" {
|
||||
streamURL = bestURL
|
||||
}
|
||||
|
||||
proxyURL := "/video_proxy?url=" + url.QueryEscape(streamURL)
|
||||
|
||||
// Get audio URL for the response
|
||||
audioProxyURL := ""
|
||||
if audioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"original_url": info.StreamURL,
|
||||
"stream_url": proxyURL,
|
||||
"audio_url": audioProxyURL,
|
||||
"title": info.Title,
|
||||
"description": info.Description,
|
||||
"uploader": info.Uploader,
|
||||
"channel_id": info.ChannelID,
|
||||
"uploader_id": info.UploaderID,
|
||||
"view_count": info.ViewCount,
|
||||
"thumbnail": info.Thumbnail,
|
||||
"related": []interface{}{},
|
||||
"subtitle_url": nil,
|
||||
"qualities": qualityOptions,
|
||||
"best_quality": bestHeight,
|
||||
})
|
||||
}
|
||||
|
||||
// Get video qualities
|
||||
func handleGetQualities(c *gin.Context) {
|
||||
videoID := c.Param("id")
|
||||
func handleDownload(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
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)
|
||||
}
|
||||
|
||||
func handleGetQualities(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
|
||||
if err != nil {
|
||||
log.Printf("GetQualities error: %v", err)
|
||||
log.Printf("GetVideoQualities Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
|
||||
return
|
||||
}
|
||||
|
||||
var result []gin.H
|
||||
for _, q := range qualities {
|
||||
proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL)
|
||||
audioProxyURL := ""
|
||||
if q.AudioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL)
|
||||
}
|
||||
result = append(result, gin.H{
|
||||
"format_id": q.FormatID,
|
||||
"label": q.Label,
|
||||
"resolution": q.Resolution,
|
||||
"height": q.Height,
|
||||
"url": proxyURL,
|
||||
"audio_url": audioProxyURL,
|
||||
"is_hls": q.IsHLS,
|
||||
"vcodec": q.VCodec,
|
||||
"acodec": q.ACodec,
|
||||
"filesize": q.Filesize,
|
||||
"has_audio": q.HasAudio,
|
||||
})
|
||||
}
|
||||
|
||||
// Also return the best audio URL separately
|
||||
audioProxyURL := ""
|
||||
if audioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"qualities": qualities,
|
||||
"audio_url": audioURL,
|
||||
"success": true,
|
||||
"qualities": result,
|
||||
"audio_url": audioProxyURL,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetStreamByQuality(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
heightStr := c.Query("q")
|
||||
height := 0
|
||||
if heightStr != "" {
|
||||
if parsed, err := strconv.Atoi(heightStr); err == nil {
|
||||
height = parsed
|
||||
}
|
||||
}
|
||||
|
||||
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
|
||||
if err != nil {
|
||||
log.Printf("GetVideoQualities Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(qualities) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "No qualities available"})
|
||||
return
|
||||
}
|
||||
|
||||
var selected *services.QualityFormat
|
||||
for i := range qualities {
|
||||
if qualities[i].Height == height {
|
||||
selected = &qualities[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selected == nil {
|
||||
selected = &qualities[0]
|
||||
}
|
||||
|
||||
proxyURL := "/video_proxy?url=" + url.QueryEscape(selected.URL)
|
||||
|
||||
audioProxyURL := ""
|
||||
if selected.AudioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(selected.AudioURL)
|
||||
} else if audioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"stream_url": proxyURL,
|
||||
"audio_url": audioProxyURL,
|
||||
"has_audio": selected.HasAudio,
|
||||
"quality": gin.H{
|
||||
"label": selected.Label,
|
||||
"height": selected.Height,
|
||||
"is_hls": selected.IsHLS,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Get related videos
|
||||
func handleRelatedVideos(c *gin.Context) {
|
||||
videoID := c.Param("id")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
videoID := c.Query("v")
|
||||
title := c.Query("title")
|
||||
uploader := c.Query("uploader")
|
||||
|
||||
if title == "" && videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID or Title required"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.Query("limit")
|
||||
limit := 15
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||
limit := 10
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
// First get video info to get title and uploader
|
||||
video, err := services.GetVideoInfo(videoID)
|
||||
if err != nil {
|
||||
log.Printf("GetVideoInfo for related error: %v", err)
|
||||
// Fallback: search for similar content
|
||||
results, err := services.SearchVideos("music", limit)
|
||||
videos, err := services.GetRelatedVideos(title, uploader, limit)
|
||||
if err != nil {
|
||||
log.Printf("GetRelatedVideos Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, results)
|
||||
return
|
||||
}
|
||||
|
||||
related, err := services.GetRelatedVideos(video.Title, video.Uploader, limit)
|
||||
if err != nil {
|
||||
log.Printf("GetRelatedVideos error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, related)
|
||||
}
|
||||
|
||||
// Get video comments
|
||||
func handleComments(c *gin.Context) {
|
||||
videoID := c.Param("id")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.Query("limit")
|
||||
limit := 20
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
comments, err := services.GetComments(videoID, limit)
|
||||
if err != nil {
|
||||
log.Printf("GetComments error: %v", err)
|
||||
c.JSON(http.StatusOK, []interface{}{}) // Return empty array instead of error
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, comments)
|
||||
}
|
||||
|
||||
// Get download URL
|
||||
func handleDownload(c *gin.Context) {
|
||||
videoID := c.Param("id")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
formatID := c.Query("format")
|
||||
|
||||
downloadInfo, 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, downloadInfo)
|
||||
}
|
||||
|
||||
// Get channel info
|
||||
func handleChannelInfo(c *gin.Context) {
|
||||
channelID := c.Query("id")
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
channelInfo, err := services.GetChannelInfo(channelID)
|
||||
if err != nil {
|
||||
log.Printf("GetChannelInfo error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel info"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, channelInfo)
|
||||
}
|
||||
|
||||
// Get channel videos
|
||||
func handleChannelVideos(c *gin.Context) {
|
||||
channelID := c.Query("id")
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.Query("limit")
|
||||
limit := 30
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
videos, err := services.GetChannelVideos(channelID, limit)
|
||||
if err != nil {
|
||||
log.Printf("GetChannelVideos error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, videos)
|
||||
}
|
||||
|
||||
// History handlers
|
||||
func handleTranscript(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not Implemented"})
|
||||
}
|
||||
|
||||
func handleComments(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
comments, err := services.GetComments(videoID, limit)
|
||||
if err != nil {
|
||||
log.Printf("GetComments Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get comments"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, comments)
|
||||
}
|
||||
|
||||
func handleChannelInfo(c *gin.Context) {
|
||||
channelID := c.Query("id")
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := services.GetChannelInfo(channelID)
|
||||
if err != nil {
|
||||
log.Printf("GetChannelInfo Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel info"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
func handleChannelVideos(c *gin.Context) {
|
||||
channelID := c.Query("id")
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.Query("limit")
|
||||
limit := 30
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
videos, err := services.GetChannelVideos(channelID, limit)
|
||||
if err != nil {
|
||||
log.Printf("GetChannelVideos Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, videos)
|
||||
}
|
||||
|
||||
func handleVideoProxy(c *gin.Context) {
|
||||
targetURL := c.Query("url")
|
||||
if targetURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No URL provided"})
|
||||
return
|
||||
}
|
||||
|
||||
// SSRF Protection: Validate target domain
|
||||
if err := isAllowedDomain(targetURL); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "URL domain not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Forward standard headers
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||
req.Header.Set("Referer", "https://www.youtube.com/")
|
||||
req.Header.Set("Origin", "https://www.youtube.com")
|
||||
|
||||
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
baseURL := targetURL[:strings.LastIndex(targetURL, "/")]
|
||||
|
||||
isManifest := strings.Contains(strings.ToLower(contentType), "mpegurl") ||
|
||||
strings.HasSuffix(targetURL, ".m3u8") ||
|
||||
strings.Contains(targetURL, ".m3u8")
|
||||
|
||||
if isManifest && (resp.StatusCode == 200 || resp.StatusCode == 206) {
|
||||
// Rewrite M3U8 Manifest
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
var newLines []string
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
fullURL := line
|
||||
if !strings.HasPrefix(line, "http") {
|
||||
fullURL = baseURL + "/" + line
|
||||
}
|
||||
encodedURL := url.QueryEscape(fullURL)
|
||||
newLines = append(newLines, "/video_proxy?url="+encodedURL)
|
||||
} else {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
rewrittenContent := strings.Join(newLines, "\n")
|
||||
c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewrittenContent))
|
||||
return
|
||||
}
|
||||
|
||||
// Stream binary video data
|
||||
for k, v := range resp.Header {
|
||||
logKey := strings.ToLower(k)
|
||||
if logKey != "content-encoding" && logKey != "transfer-encoding" && logKey != "connection" && !strings.HasPrefix(logKey, "access-control-") {
|
||||
c.Writer.Header()[k] = v
|
||||
}
|
||||
}
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
func handlePostHistory(c *gin.Context) {
|
||||
var body struct {
|
||||
VideoID string `json:"video_id"`
|
||||
|
|
@ -343,13 +707,14 @@ func handleGetHistory(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Make the API response shape match the VideoData shape the frontend expects
|
||||
// We'll reconstruct a basic VideoData-like array for the frontend
|
||||
var results []services.VideoData
|
||||
for _, h := range history {
|
||||
results = append(results, services.VideoData{
|
||||
ID: h.ID,
|
||||
Title: h.Title,
|
||||
Thumbnail: h.Thumbnail,
|
||||
Uploader: "History",
|
||||
Uploader: "History", // Just a placeholder
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -372,7 +737,6 @@ func handleGetSuggestions(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, suggestions)
|
||||
}
|
||||
|
||||
// Subscription handlers
|
||||
func handleSubscribe(c *gin.Context) {
|
||||
var body struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
|
|
@ -440,7 +804,3 @@ func handleGetSubscriptions(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
func logPrintf(format string, v ...interface{}) {
|
||||
log.Printf(format, v...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package services
|
|||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"kvtube-go/models"
|
||||
)
|
||||
|
|
@ -66,10 +67,30 @@ func GetHistory(limit int) ([]HistoryVideo, error) {
|
|||
}
|
||||
|
||||
// GetSuggestions retrieves suggestions based on the user's recent history
|
||||
// NOTE: This function now returns empty results since we're using client-side YouTube API
|
||||
// The frontend should use the YouTube API directly for suggestions
|
||||
func GetSuggestions(limit int) ([]VideoData, error) {
|
||||
// Return empty results - suggestions are now handled client-side
|
||||
// Frontend should use YouTube API for suggestions
|
||||
return []VideoData{}, nil
|
||||
// 1. Get the 3 most recently watched videos to extract keywords
|
||||
history, err := GetHistory(3)
|
||||
if err != nil || len(history) == 0 {
|
||||
// Fallback to trending if no history
|
||||
return SearchVideos("trending videos", limit)
|
||||
}
|
||||
|
||||
// 2. Build a combined query string from titles
|
||||
var words []string
|
||||
for _, h := range history {
|
||||
// take first few words from title
|
||||
parts := strings.Fields(h.Title)
|
||||
for i := 0; i < len(parts) && i < 3; i++ {
|
||||
// clean up some common punctuation if needed, or just let yt-dlp handle it
|
||||
words = append(words, parts[i])
|
||||
}
|
||||
}
|
||||
|
||||
query := strings.Join(words, " ")
|
||||
if query == "" {
|
||||
query = "popular videos"
|
||||
}
|
||||
|
||||
// 3. Search using yt-dlp
|
||||
return SearchVideos(query, limit)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,8 +107,7 @@ func sanitizeVideoData(entry YtDlpEntry) VideoData {
|
|||
|
||||
thumbnail := ""
|
||||
if 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)
|
||||
thumbnail = fmt.Sprintf("https://i.ytimg.com/vi/%s/maxresdefault.jpg", entry.ID)
|
||||
}
|
||||
|
||||
return VideoData{
|
||||
|
|
@ -252,23 +251,15 @@ func GetVideoInfo(videoID string) (*VideoData, error) {
|
|||
url,
|
||||
}
|
||||
|
||||
// Skip cache for now to avoid corrupted data issues
|
||||
out, err := RunYtDlp(args...)
|
||||
cacheKey := "video_info:" + videoID
|
||||
out, err := RunYtDlpCached(cacheKey, 3600, args...) // Cache for 1 hour
|
||||
if err != nil {
|
||||
log.Printf("yt-dlp failed for %s: %v", videoID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log first 500 chars for debugging
|
||||
if len(out) > 0 {
|
||||
log.Printf("yt-dlp response for %s (first 200 chars): %s", videoID, string(out[:min(200, len(out))]))
|
||||
}
|
||||
|
||||
var entry YtDlpEntry
|
||||
if err := json.Unmarshal(out, &entry); err != nil {
|
||||
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)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := sanitizeVideoData(entry)
|
||||
|
|
@ -277,13 +268,6 @@ func GetVideoInfo(videoID string) (*VideoData, error) {
|
|||
return &data, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type QualityFormat struct {
|
||||
FormatID string `json:"format_id"`
|
||||
Label string `json:"label"`
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
# KV-Tube Docker Compose for Synology NAS
|
||||
# Usage: docker-compose up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
kv-tube:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v9
|
||||
container_name: kv-tube
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5011:3000"
|
||||
- "8981:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- KVTUBE_DATA_DIR=/app/data
|
||||
- GIN_MODE=release
|
||||
- NODE_ENV=production
|
||||
- CORS_ALLOWED_ORIGINS=https://ut.khoavo.myds.me,http://ut.khoavo.myds.me:5011,http://localhost:3000,http://127.0.0.1:3000
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
|
@ -5,24 +5,19 @@ version: '3.8'
|
|||
|
||||
services:
|
||||
kv-tube:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=http://ut.khoavo.myds.me:8981/api
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v7
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v5
|
||||
container_name: kv-tube
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5011:3000"
|
||||
- "8981:8080"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- KVTUBE_DATA_DIR=/app/data
|
||||
- GIN_MODE=release
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=http://ut.khoavo.myds.me:8981/api
|
||||
- CORS_ALLOWED_ORIGINS=https://ut.khoavo.myds.me,http://ut.khoavo.myds.me:5011,http://localhost:3000,http://127.0.0.1:3000
|
||||
- NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
|
|
|||
33
fix_urls.js
Normal file
33
fix_urls.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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");
|
||||
|
|
@ -1,621 +0,0 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
41
frontend/app/api/download/route.ts
Normal file
41
frontend/app/api/download/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
26
frontend/app/api/formats/route.ts
Normal file
26
frontend/app/api/formats/route.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
35
frontend/app/api/history/route.ts
Normal file
35
frontend/app/api/history/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
42
frontend/app/api/proxy-file/route.ts
Normal file
42
frontend/app/api/proxy-file/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
42
frontend/app/api/proxy-stream/route.ts
Normal file
42
frontend/app/api/proxy-stream/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
33
frontend/app/api/stream/route.ts
Normal file
33
frontend/app/api/stream/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
80
frontend/app/api/subscribe/route.ts
Normal file
80
frontend/app/api/subscribe/route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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,11 +27,9 @@ function formatSubscribers(count: number): string {
|
|||
|
||||
// We no longer need getAvatarColor as we now use the global --yt-avatar-bg
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api';
|
||||
|
||||
async function getChannelInfo(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/channel/info?id=${id}`, { cache: 'no-store' });
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/info?id=${id}`, { cache: 'no-store' });
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<ChannelInfo>;
|
||||
} catch (e) {
|
||||
|
|
@ -42,7 +40,7 @@ async function getChannelInfo(id: string) {
|
|||
|
||||
async function getChannelVideos(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/channel/videos?id=${id}&limit=30`, { cache: 'no-store' });
|
||||
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' });
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<VideoData[]>;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { VideoData } from './constants';
|
||||
|
||||
// Use relative URLs - Next.js rewrites will proxy to backend
|
||||
const API_BASE = '/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 = [
|
||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||
import VideoCard from './VideoCard';
|
||||
import { fetchMoreVideos } from '../actions';
|
||||
import { VideoData } from '../constants';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
interface Props {
|
||||
initialVideos: VideoData[];
|
||||
|
|
@ -101,7 +100,16 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi
|
|||
|
||||
{hasMore && (
|
||||
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{isLoading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
'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 = [
|
||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ export default function RegionSelector() {
|
|||
setSelected(code);
|
||||
setRegionCookie(code);
|
||||
setIsOpen(false);
|
||||
// Dispatch custom event for immediate notification
|
||||
window.dispatchEvent(new CustomEvent('regionchange', { detail: { region: code } }));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function Sidebar() {
|
|||
const navItems = [
|
||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,19 @@
|
|||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { VideoData } from '@/app/constants';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
channel_id?: string;
|
||||
thumbnail: string;
|
||||
view_count: number;
|
||||
duration: string;
|
||||
uploaded_date?: string;
|
||||
list_id?: string;
|
||||
is_mix?: boolean;
|
||||
}
|
||||
|
||||
function formatViews(views: number): string {
|
||||
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
||||
|
|
@ -12,10 +23,10 @@ function formatViews(views: number): string {
|
|||
return views.toString();
|
||||
}
|
||||
|
||||
function getStableRelativeTime(id: string): string {
|
||||
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 hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return times[hash % times.length];
|
||||
const index = (id.charCodeAt(0) || 0) % times.length;
|
||||
return times[index];
|
||||
}
|
||||
|
||||
import { memo } from 'react';
|
||||
|
|
@ -23,7 +34,7 @@ import { memo } from 'react';
|
|||
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
|
||||
|
||||
function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
|
||||
const relativeTime = video.upload_date || video.publishedAt || getStableRelativeTime(video.id);
|
||||
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
const destination = video.list_id ? `/watch?v=${video.id}&list=${video.list_id}` : `/watch?v=${video.id}`;
|
||||
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
||||
|
|
@ -78,7 +89,13 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
|
|||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<LoadingSpinner color="white" />
|
||||
<div style={{
|
||||
width: '40px', height: '40px',
|
||||
border: '3px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: '#fff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
|
@ -94,15 +111,15 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
|
|||
<div style={{ marginTop: '4px' }}>
|
||||
{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">
|
||||
{video.uploader || video.channelTitle || 'Unknown'}
|
||||
{video.uploader}
|
||||
</Link>
|
||||
) : (
|
||||
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', display: 'block' }}>
|
||||
{video.uploader || video.channelTitle || 'Unknown'}
|
||||
{video.uploader}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||
{formatViews(video.view_count ?? 0)} views • {relativeTime}
|
||||
{formatViews(video.view_count)} views • {relativeTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,12 @@
|
|||
export const API_BASE = ''; // No backend needed - using public APIs
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
|
||||
|
||||
export interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
thumbnail: string;
|
||||
channelTitle?: string;
|
||||
channelId?: string;
|
||||
viewCount?: string;
|
||||
publishedAt?: string;
|
||||
view_count: number;
|
||||
duration: string;
|
||||
description?: string;
|
||||
// Legacy fields for compatibility
|
||||
uploader?: string;
|
||||
uploader_id?: string;
|
||||
channel_id?: string;
|
||||
view_count?: number;
|
||||
upload_date?: string;
|
||||
avatar_url?: string;
|
||||
list_id?: string;
|
||||
is_mix?: boolean;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getSavedVideos, type SavedVideo } from '../../storage';
|
||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||
|
||||
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
|
||||
|
||||
|
|
@ -147,94 +145,24 @@ 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() {
|
||||
const [history, setHistory] = useState<VideoData[]>([]);
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||
const [savedVideos, setSavedVideos] = useState<SavedVideo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api';
|
||||
const [historyRes, subsRes] = await Promise.all([
|
||||
fetch(`${apiBase}/history?limit=20`, { cache: 'no-store' }),
|
||||
fetch(`${apiBase}/subscriptions`, { cache: 'no-store' })
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/history?limit=20`, { cache: 'no-store' }),
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' })
|
||||
]);
|
||||
|
||||
const historyData = await historyRes.json();
|
||||
const subsData = await subsRes.json();
|
||||
const savedData = getSavedVideos(20);
|
||||
|
||||
setHistory(Array.isArray(historyData) ? historyData : []);
|
||||
setSubscriptions(Array.isArray(subsData) ? subsData : []);
|
||||
setSavedVideos(savedData);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch library data:', err);
|
||||
} finally {
|
||||
|
|
@ -246,8 +174,15 @@ export default function LibraryPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
|
||||
<LoadingSpinner />
|
||||
<div style={{ padding: '48px', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -257,7 +192,7 @@ export default function LibraryPage() {
|
|||
{subscriptions.length > 0 && (
|
||||
<section style={{ marginBottom: '40px' }}>
|
||||
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||
Sub
|
||||
Subscriptions
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
{subscriptions.map((sub) => (
|
||||
|
|
@ -267,23 +202,6 @@ export default function LibraryPage() {
|
|||
</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>
|
||||
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||
Watch History
|
||||
|
|
|
|||
|
|
@ -2,37 +2,30 @@
|
|||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getChannelVideosClient, getChannelInfoClient } from '../../clientActions';
|
||||
import { VideoData } from '../../constants';
|
||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api';
|
||||
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
|
||||
|
||||
interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
channel_id: string;
|
||||
thumbnail: string;
|
||||
view_count: number;
|
||||
duration: string;
|
||||
uploaded_date?: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
channel_id: string;
|
||||
channel_name: 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 {
|
||||
subscription: Subscription;
|
||||
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;
|
||||
|
|
@ -45,6 +38,12 @@ function formatViews(views: number): string {
|
|||
return views.toString();
|
||||
}
|
||||
|
||||
function getRelativeTime(id: string): string {
|
||||
const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago'];
|
||||
const index = (id.charCodeAt(0) || 0) % times.length;
|
||||
return times[index];
|
||||
}
|
||||
|
||||
function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVideos: ChannelVideos; defaultExpanded?: boolean }) {
|
||||
const { subscription, videos } = channelVideos;
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
|
@ -86,17 +85,12 @@ function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVid
|
|||
fontSize: '18px',
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{subscription.channel_avatar ? (
|
||||
<img src={subscription.channel_avatar} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'
|
||||
)}
|
||||
{subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'}
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', fontWeight: '500', color: 'var(--yt-text-primary)', textAlign: 'center' }}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '500', color: 'var(--yt-text-primary)' }}>
|
||||
{subscription.channel_name || subscription.channel_id}
|
||||
</span>
|
||||
</h2>
|
||||
</Link>
|
||||
|
||||
<div style={{
|
||||
|
|
@ -106,7 +100,7 @@ function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVid
|
|||
padding: '0 12px',
|
||||
}}>
|
||||
{displayedVideos.map((video) => {
|
||||
const relativeTime = video.publishedAt || video.upload_date || 'recently';
|
||||
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
|
||||
const destination = `/watch?v=${video.id}`;
|
||||
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
||||
|
||||
|
|
@ -148,7 +142,7 @@ function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVid
|
|||
{video.title}
|
||||
</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', margin: 0 }}>
|
||||
{video.viewCount || formatViews(video.view_count || 0)} views • {relativeTime}
|
||||
{formatViews(video.view_count)} views • {relativeTime}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
|
|
@ -195,35 +189,27 @@ export default function SubscriptionsPage() {
|
|||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const subs = await fetchSubscriptions();
|
||||
const subsRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
|
||||
const subsData = await subsRes.json();
|
||||
const subs = Array.isArray(subsData) ? subsData : [];
|
||||
|
||||
const channelVideos: 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);
|
||||
|
||||
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) {
|
||||
return {
|
||||
channelVideos.push({
|
||||
subscription: sub,
|
||||
videos: videos,
|
||||
channelInfo: channelInfo || null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch videos for ${sub.channel_id}:`, err);
|
||||
return null;
|
||||
}
|
||||
videos: videos.map((v: VideoData) => ({ ...v, uploader: sub.channel_name }))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const validResults = results.filter((r): r is ChannelVideos => r !== null);
|
||||
|
||||
setChannelsVideos(validResults);
|
||||
setChannelsVideos(channelVideos);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch subscriptions:', err);
|
||||
} finally {
|
||||
|
|
@ -235,8 +221,15 @@ export default function SubscriptionsPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
|
||||
<LoadingSpinner />
|
||||
<div style={{ padding: '48px', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -246,28 +239,13 @@ export default function SubscriptionsPage() {
|
|||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--yt-text-secondary)' }}>
|
||||
<h2 style={{ marginBottom: '16px', color: 'var(--yt-text-primary)' }}>No subscriptions yet</h2>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px', padding: '0 12px' }}>Sub</h1>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px', padding: '0 12px' }}>Subscriptions</h1>
|
||||
|
||||
{channelsVideos.map((channelData) => (
|
||||
<ChannelSection key={channelData.subscription.channel_id} channelVideos={channelData} />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,160 @@
|
|||
import { Suspense } from 'react';
|
||||
import ClientHomePage from './ClientHomePage';
|
||||
import LoadingSpinner from './components/LoadingSpinner';
|
||||
import Link from 'next/link';
|
||||
import { cookies } from 'next/headers';
|
||||
import InfiniteVideoGrid from './components/InfiniteVideoGrid';
|
||||
import VideoCard from './components/VideoCard';
|
||||
import {
|
||||
getSearchVideos,
|
||||
getHistoryVideos,
|
||||
getSuggestedVideos,
|
||||
getRelatedVideos,
|
||||
getRecentHistory
|
||||
} from './actions';
|
||||
import {
|
||||
VideoData,
|
||||
CATEGORY_MAP,
|
||||
ALL_CATEGORY_SECTIONS,
|
||||
addRegion,
|
||||
getRandomModifier
|
||||
} from './utils';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
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 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);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner fullScreen text="Loading videos..." />}>
|
||||
<ClientHomePage />
|
||||
</Suspense>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
'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,21 +1,188 @@
|
|||
export const dynamic = 'force-dynamic';
|
||||
import { Suspense } from 'react';
|
||||
import ClientSearchPage from './ClientSearchPage';
|
||||
import Link from 'next/link';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export default function SearchPage() {
|
||||
interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
channel_id?: string;
|
||||
thumbnail: string;
|
||||
view_count: number;
|
||||
duration: string;
|
||||
description: string;
|
||||
avatar_url?: string;
|
||||
uploaded_date?: string;
|
||||
}
|
||||
|
||||
function formatViews(views: number): string {
|
||||
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
||||
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
|
||||
return views.toString();
|
||||
}
|
||||
|
||||
async function fetchSearchResults(query: string) {
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/search?q=${encodeURIComponent(query)}`, { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<VideoData[]>;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function SearchSkeleton() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#0f0f0f',
|
||||
color: '#fff',
|
||||
}}>
|
||||
Searching...
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', maxWidth: '1096px', margin: '0 auto' }}>
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} style={{ display: 'flex', gap: '16px' }} className={`fade-in-up stagger-${i}`}>
|
||||
<div className="skeleton" style={{ width: '360px', minWidth: '360px', aspectRatio: '16/9', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px', paddingTop: '4px' }}>
|
||||
<div className="skeleton skeleton-line" style={{ width: '90%', height: '18px' }} />
|
||||
<div className="skeleton skeleton-line" style={{ width: '70%', height: '18px' }} />
|
||||
<div className="skeleton skeleton-line-short" style={{ marginTop: '8px' }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
|
||||
<div className="skeleton skeleton-avatar" style={{ width: '24px', height: '24px' }} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
}>
|
||||
<ClientSearchPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
// 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,7 +2,6 @@
|
|||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { IoHeart, IoHeartOutline, IoChatbubbleOutline, IoShareOutline, IoEllipsisHorizontal, IoMusicalNote, IoRefresh, IoPlay, IoVolumeMute, IoVolumeHigh } from 'react-icons/io5';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -226,7 +225,7 @@ function ShortCard({ video, isActive }: { video: ShortVideo; isActive: boolean }
|
|||
/>
|
||||
{loading && (
|
||||
<div style={loadingOverlayStyle}>
|
||||
<LoadingSpinner color="white" />
|
||||
<div style={spinnerStyle}></div>
|
||||
</div>
|
||||
)}
|
||||
{error && !useFallback && (
|
||||
|
|
@ -414,6 +413,15 @@ const openBtnStyle: React.CSSProperties = {
|
|||
zIndex: 10,
|
||||
};
|
||||
|
||||
const spinnerStyle: React.CSSProperties = {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #333',
|
||||
borderTopColor: '#ff0050',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
};
|
||||
|
||||
export default function ShortsPage() {
|
||||
const [shorts, setShorts] = useState<ShortVideo[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
|
@ -465,7 +473,7 @@ export default function ShortsPage() {
|
|||
if (loading) return (
|
||||
<div style={pageStyle}>
|
||||
<div style={{ ...spinnerContainerStyle, width: '300px', height: '500px' }}>
|
||||
<LoadingSpinner color="white" />
|
||||
<div style={spinnerStyle}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -484,10 +492,11 @@ export default function ShortsPage() {
|
|||
return (
|
||||
<div ref={containerRef} style={scrollContainerStyle}>
|
||||
<style>{hideScrollbarCss}</style>
|
||||
<style>{spinCss}</style>
|
||||
{shorts.map((v, i) => <ShortCard key={v.id} video={v} isActive={i === activeIndex} />)}
|
||||
{loadingMore && (
|
||||
<div style={{ ...pageStyle, height: '100vh' }}>
|
||||
<LoadingSpinner color="white" />
|
||||
<div style={spinnerStyle}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -497,4 +506,5 @@ export default function ShortsPage() {
|
|||
const pageStyle: React.CSSProperties = { height: 'calc(100vh - 56px)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f0f0f' };
|
||||
const scrollContainerStyle: React.CSSProperties = { height: 'calc(100vh - 56px)', overflowY: 'scroll', scrollSnapType: 'y mandatory', background: '#0f0f0f', scrollbarWidth: 'none' };
|
||||
const spinnerContainerStyle: React.CSSProperties = { borderRadius: '12px', background: 'linear-gradient(180deg, #1a1a1a 0%, #0f0f0f 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' };
|
||||
const spinCss = '@keyframes spin { to { transform: rotate(360deg); } }';
|
||||
const hideScrollbarCss = 'div::-webkit-scrollbar { display: none; }';
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
'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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,996 +0,0 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
198
frontend/app/watch/Comments.tsx
Normal file
198
frontend/app/watch/Comments.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
11
frontend/app/watch/NextVideoClient.tsx
Normal file
11
frontend/app/watch/NextVideoClient.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'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;
|
||||
}
|
||||
150
frontend/app/watch/PlaylistPanel.tsx
Normal file
150
frontend/app/watch/PlaylistPanel.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
95
frontend/app/watch/RelatedVideos.tsx
Normal file
95
frontend/app/watch/RelatedVideos.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
722
frontend/app/watch/VideoPlayer.tsx
Normal file
722
frontend/app/watch/VideoPlayer.tsx
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
'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' };
|
||||
431
frontend/app/watch/WatchActions.tsx
Normal file
431
frontend/app/watch/WatchActions.tsx
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
77
frontend/app/watch/WatchFeed.tsx
Normal file
77
frontend/app/watch/WatchFeed.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
'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,11 +1,272 @@
|
|||
import { Suspense } from 'react';
|
||||
import ClientWatchPage from './ClientWatchPage';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import Link from 'next/link';
|
||||
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 (
|
||||
<Suspense fallback={<LoadingSpinner fullScreen text="Loading video..." />}>
|
||||
<ClientWatchPage />
|
||||
</Suspense>
|
||||
<div className="watch-container fade-in">
|
||||
{nextVideoId && <NextVideoClient videoId={nextVideoId} listId={nextListId} />}
|
||||
<div className="watch-primary">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -14,13 +14,16 @@ const nextConfig = {
|
|||
],
|
||||
},
|
||||
async rewrites() {
|
||||
// Backend runs on port 8080 inside the container
|
||||
const apiBase = 'http://localhost:8080';
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${apiBase}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/video_proxy',
|
||||
destination: `${apiBase}/video_proxy`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,12 +9,18 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clappr/core": "^0.13.2",
|
||||
"@clappr/player": "^0.11.16",
|
||||
"@fontsource/roboto": "^5.2.9",
|
||||
"@vidstack/react": "^1.12.13",
|
||||
"artplayer": "^5.3.0",
|
||||
"clappr": "^0.3.13",
|
||||
"hls.js": "^1.6.15",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-icons": "^5.5.0"
|
||||
"react-icons": "^5.5.0",
|
||||
"vidstack": "^1.12.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
1
frontend/tmp/check_main_content.js
Normal file
1
frontend/tmp/check_main_content.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
console.log(document.querySelector(".yt-main-content").style.marginLeft); console.log(window.getComputedStyle(document.querySelector(".yt-main-content")).marginLeft);
|
||||
8
page.html
Normal file
8
page.html
Normal file
File diff suppressed because one or more lines are too long
36
test-api.js
36
test-api.js
|
|
@ -1,36 +0,0 @@
|
|||
// 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