Compare commits
93 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
970c2f920a | ||
|
|
eb011f720b | ||
|
|
5d2e28dd99 | ||
|
|
1bdbffbc99 | ||
|
|
e44d1b8b5a | ||
|
|
ccee56aff2 | ||
|
|
3b2078b203 | ||
|
|
601fce07f8 | ||
|
|
61a627483d | ||
|
|
971d3e4b8f | ||
|
|
6e766a28ae | ||
|
|
cfcf0a2800 | ||
|
|
729bfbe49e | ||
|
|
871d506f83 | ||
|
|
6a70fc8438 | ||
|
|
cbb487d307 | ||
|
|
ab37e8408a | ||
|
|
494b672aba | ||
|
|
f289ff86b0 | ||
|
|
f14f0ac299 | ||
|
|
cd8a69f1ad | ||
|
|
db7619b975 | ||
|
|
dce0f4c5f6 | ||
|
|
9b23ba183b | ||
|
|
77418ef60b | ||
|
|
24f6d524e2 | ||
|
|
1c867043fc | ||
|
|
59e97f805e | ||
|
|
8c1c7ec764 | ||
|
|
9c184b666c | ||
|
|
7fcff298c2 | ||
|
|
0bbe4fe015 | ||
|
|
f13e988e17 | ||
|
|
ca3a64539b | ||
|
|
f5f356d695 | ||
|
|
454ef227eb | ||
|
|
17912515a7 | ||
|
|
9ce894523e | ||
|
|
efa0c6ff30 | ||
|
|
a7faaf82e2 | ||
|
|
70c1c337bf | ||
|
|
a4acfacc49 | ||
|
|
40f7276086 | ||
|
|
d4530682f9 | ||
|
|
6433532a28 | ||
|
|
e255bfea22 | ||
|
|
f2f28735a0 | ||
|
|
01eca0d7d5 | ||
|
|
c179e7dc33 | ||
|
|
364f4d8780 | ||
|
|
376c5a9bed | ||
|
|
e0f9fe6842 | ||
|
|
b1bd08ba7a | ||
|
|
c99a772b54 | ||
|
|
df02bc9801 | ||
|
|
68824d70ff | ||
|
|
f3ac7c99b8 | ||
|
|
acdafcfe8c | ||
|
|
f6bbfc981a | ||
|
|
d78d4a6b66 | ||
|
|
799a3ffb15 | ||
|
|
787e42d435 | ||
|
|
468b2b08fc | ||
|
|
82a51b7ee4 | ||
|
|
1a04f8f486 | ||
|
|
86913861f2 | ||
|
|
16e146ce11 | ||
|
|
b079c426d7 | ||
|
|
bdfd537c6e | ||
|
|
b7de4adc00 | ||
|
|
80bfc4f602 | ||
|
|
0c51e3c888 | ||
|
|
589c104694 | ||
|
|
8ea5f2b09f | ||
|
|
1cb7a73a61 | ||
|
|
729c5440ad | ||
|
|
42f8eaff27 | ||
|
|
80f2f3725f | ||
|
|
8844007f18 | ||
|
|
714534389c | ||
|
|
e05c4b9654 | ||
|
|
a8a562544e | ||
|
|
fd449cce45 | ||
|
|
657f54855b | ||
|
|
7c00855c4c | ||
|
|
b7ea9165a1 | ||
|
|
21df1d1b8c | ||
|
|
ddb64e2ce3 | ||
|
|
4c5bccbd61 | ||
|
|
c49d827296 | ||
|
|
66d95e0fb4 | ||
|
|
bc1be07967 | ||
|
|
57d8fc31ab |
36
.dockerignore
Executable file → Normal file
|
|
@ -1,16 +1,22 @@
|
|||
.venv/
|
||||
.venv_clean/
|
||||
env/
|
||||
__pycache__/
|
||||
.git/
|
||||
frontend/node_modules
|
||||
frontend/.next
|
||||
frontend/dist
|
||||
frontend/build
|
||||
backend/bin
|
||||
backend/logs
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.DS_Store
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.idea/
|
||||
.vscode/
|
||||
videos/
|
||||
data/
|
||||
venv/
|
||||
.gemini/
|
||||
tmp*/
|
||||
videos
|
||||
data
|
||||
venv
|
||||
.gemini
|
||||
tmp*
|
||||
*.exe
|
||||
*.mac
|
||||
*-mac
|
||||
*-new
|
||||
page.html
|
||||
build-temp
|
||||
.dockerignore.bak
|
||||
|
|
|
|||
19
.env.example
Executable file → Normal file
|
|
@ -9,3 +9,22 @@ KVTUBE_DATA_DIR=./data
|
|||
|
||||
# Gin mode: debug or release
|
||||
GIN_MODE=release
|
||||
|
||||
# CORS allowed origins (comma-separated, or * for all)
|
||||
# Example: CORS_ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# Database configuration
|
||||
DB_MAX_OPEN_CONNS=25
|
||||
DB_MAX_IDLE_CONNS=5
|
||||
DB_CONN_MAX_LIFETIME=5m
|
||||
|
||||
# Cache configuration
|
||||
CACHE_TTL=3600
|
||||
CACHE_ENABLED=true
|
||||
|
||||
# HTTP client configuration
|
||||
HTTP_CLIENT_TIMEOUT=30s
|
||||
|
||||
# Security
|
||||
# Note: SSRF protection is enabled for video proxy - only YouTube/Google domains allowed
|
||||
|
|
|
|||
4
.forgejo/test.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
test
|
||||
test
|
||||
ci test Sat Mar 28 11:26:45 +07 2026
|
||||
test Sat Mar 28 14:46:11 +07 2026
|
||||
26
.forgejo/workflows/docker-build.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
cd /tmp
|
||||
rm -rf kv-tube
|
||||
git clone https://vndangkhoa:b14bc4938aeb5f4014fa15186985a0a625f7e9b4@nas:3050/vndangkhoa/kv-tube.git
|
||||
cd kv-tube
|
||||
git checkout ${GITEA_SHA:-main}
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
cd /tmp/kv-tube
|
||||
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||
IMAGE="git.khoavo.myds.me/vndangkhoa/kv-tube"
|
||||
docker build -t ${IMAGE}:${SHA_SHORT} .
|
||||
docker push ${IMAGE}:${SHA_SHORT}
|
||||
105
.github/workflows/ci.yml
vendored
|
|
@ -1,105 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ruff mypy bandit types-requests
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run Ruff
|
||||
run: ruff check . --output-format=github
|
||||
|
||||
- name: Run MyPy
|
||||
run: mypy app/ config.py --ignore-missing-imports
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Bandit
|
||||
run: bandit -r app/ -x app/routes/api --skip B101,B311
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-cov
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --tb=short
|
||||
continue-on-error: true
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- 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
|
||||
|
||||
- name: Log into Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Log into Forgejo Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.khoavo.myds.me
|
||||
username: ${{ secrets.FORGEJO_USERNAME }}
|
||||
password: ${{ secrets.FORGEJO_PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository }}
|
||||
git.khoavo.myds.me/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
70
.github/workflows/docker-publish.yml
vendored
|
|
@ -1,70 +0,0 @@
|
|||
name: Docker Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- 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
|
||||
|
||||
- name: Log into Forgejo Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.khoavo.myds.me
|
||||
username: ${{ secrets.FORGEJO_USERNAME }}
|
||||
password: ${{ secrets.FORGEJO_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (backend)
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
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 (backend)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
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
|
||||
0
.gitignore
vendored
Executable file → Normal file
72
Dockerfile
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# ---- 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 .
|
||||
|
||||
# ---- Frontend Builder ----
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
ARG NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
|
||||
WORKDIR /app
|
||||
COPY --from=frontend-deps /app/node_modules ./node_modules
|
||||
COPY frontend/ ./
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
RUN echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" && npm run build
|
||||
|
||||
# ---- Final Unified Image ----
|
||||
FROM alpine:latest
|
||||
|
||||
# Install dependencies for Go backend, Node.js frontend, and Supervisord
|
||||
RUN apk add --no-cache nodejs
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN apk add --no-cache curl
|
||||
RUN apk add --no-cache python3
|
||||
RUN apk add --no-cache py3-pip
|
||||
RUN apk add --no-cache supervisor
|
||||
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
|
||||
&& chmod a+rx /usr/local/bin/yt-dlp
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy Backend Binary
|
||||
COPY --from=backend-builder /app/kv-tube /app/kv-tube
|
||||
|
||||
# Copy Frontend Standalone App - include server.js for standalone mode
|
||||
COPY --from=frontend-builder /app/.next/standalone /app/frontend/
|
||||
COPY --from=frontend-builder /app/.next/static /app/frontend/.next/static
|
||||
COPY --from=frontend-builder /app/public /app/frontend/public
|
||||
COPY --from=frontend-builder /app/package.json /app/frontend/package.json
|
||||
COPY --from=frontend-builder /app/next.config.mjs /app/frontend/next.config.mjs
|
||||
COPY --from=frontend-builder /app/next-env.d.ts /app/frontend/next-env.d.ts
|
||||
|
||||
# Create required directories for Next.js
|
||||
RUN mkdir -p /app/frontend/.next/cache
|
||||
|
||||
# Copy Supervisord Config
|
||||
COPY supervisord.conf /etc/supervisord.conf
|
||||
|
||||
# Setup Environment
|
||||
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}
|
||||
|
||||
EXPOSE 3000 8080
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||
4
Dockerfile.diag
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
FROM alpine
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN ls -laR
|
||||
130
README.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# KV-Tube
|
||||
|
||||
A modern, fast, and fully-featured YouTube-like video streaming platform. Built with a robust Go backend and a highly responsive Next.js frontend, KV-Tube is designed for seamless deployment on systems like Synology NAS via Docker.
|
||||
|
||||
## Features
|
||||
|
||||
- **Modern Video Player**: High-resolution video playback with HLS support and quality selection.
|
||||
- **Fast Navigation**: Instant click feedback with skeleton loaders for related videos.
|
||||
- **Infinite Scrolling**: Scroll seamlessly through a dynamic video grid on the homepage.
|
||||
- **Watch History & Suggestions**: Keep track of what you've watched seamlessly! Fully integrated library history tracking.
|
||||
- **Subscriptions Management**: Keep up to date with seamless subscription updates for YouTube channels.
|
||||
- **Optimized for Safari**: Stutter-free playback algorithms and high-tolerance Hls.js configurations tailored for macOS users.
|
||||
- **Background Audio**: Allows videos to continue playing audio when the browser tab is hidden or device locked (perfect for music).
|
||||
- **Progressive Web App**: Fully installable PWA out of the box with offline fallbacks and custom vector iconography.
|
||||
- **Region Selection**: Tailor your content to specific regions (e.g., Vietnam).
|
||||
- **Responsive Design**: Beautiful, mobile-friendly interface with light and dark theme support.
|
||||
- **Containerized**: Fully Dockerized for easy setup using `docker-compose`.
|
||||
|
||||
## Architecture
|
||||
- **Backend & Frontend**: Go (Gin framework) and Next.js are combined into a single unified Docker container using a multi-stage `Dockerfile`.
|
||||
- **Process Management**: `supervisord` manages the concurrent execution of the backend API and Next.js frontend within the same network namespace.
|
||||
- **Data storage**: SQLite is used for watch history, optimized for `linux/amd64`.
|
||||
|
||||
## Docker Deployment (v5)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Clone or download this repository
|
||||
2. Create a `data` folder in the project directory
|
||||
3. Run the container:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Building the Image
|
||||
|
||||
To build the image locally:
|
||||
|
||||
```bash
|
||||
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 .
|
||||
```
|
||||
|
||||
To build and push to your registry:
|
||||
|
||||
```bash
|
||||
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 .
|
||||
docker push git.khoavo.myds.me/vndangkhoa/kv-tube:v5
|
||||
```
|
||||
|
||||
## Deployment on Synology NAS
|
||||
|
||||
We recommend using **Container Manager** (DSM 7.2+) or **Docker** (DSM 6/7.1) for a robust and easily manageable deployment.
|
||||
|
||||
### 1. Prerequisites
|
||||
- **Container Manager** or **Docker** package installed from Package Center.
|
||||
- Ensure ports `5011` (frontend) and `8981` (backend API) are available on your NAS.
|
||||
- Create a folder named `kv-tube` in your `docker` shared folder (e.g., `/volume1/docker/kv-tube`).
|
||||
|
||||
### 2. Using Container Manager (Recommended)
|
||||
|
||||
1. Open **Container Manager** > **Project** > **Create**.
|
||||
2. Set a Project Name (e.g., `kv-tube`).
|
||||
3. Set Path to `/volume1/docker/kv-tube`.
|
||||
4. Source: Select **Create docker-compose.yml** and paste the following:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
kv-tube:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v5
|
||||
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
|
||||
- NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
5. Click **Next** until the end and **Done**. The container will build and start automatically.
|
||||
|
||||
### 3. Accessing the App
|
||||
The application will be accessible at:
|
||||
- **Frontend**: `http://<your-nas-ip>:5011`
|
||||
- **Backend API**: `http://<your-nas-ip>:8981`
|
||||
- **Mobile Users**: Add to Home Screen via Safari for the full PWA experience with background playback.
|
||||
|
||||
### 4. Volume Permissions (If Needed)
|
||||
|
||||
If you encounter permission issues with the data folder, SSH into your NAS and run:
|
||||
|
||||
```bash
|
||||
# Create the data folder with proper permissions
|
||||
sudo mkdir -p /volume1/docker/kv-tube/data
|
||||
sudo chmod 755 /volume1/docker/kv-tube/data
|
||||
```
|
||||
|
||||
### 5. Updating the Container
|
||||
|
||||
To update to a new version:
|
||||
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull git.khoavo.myds.me/vndangkhoa/kv-tube:v5
|
||||
|
||||
# Restart the container
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
Or use Container Manager's built-in image update feature.
|
||||
|
||||
### 6. Troubleshooting
|
||||
|
||||
- **Container won't start**: Check logs via Container Manager or `docker logs kv-tube`
|
||||
- **Port conflicts**: Ensure ports 5011 and 8080 are not used by other services
|
||||
- **Permission denied**: Check the data folder permissions on your NAS
|
||||
- **Slow playback**: Try lowering video quality or ensure sufficient network bandwidth
|
||||
|
||||
## Development
|
||||
|
||||
- Frontend builds can be started in `frontend/` via `npm run dev`.
|
||||
- Backend server starts in `backend/` via `go run main.go`.
|
||||
11
backend/Dockerfile
Executable file → Normal file
|
|
@ -1,23 +1,24 @@
|
|||
FROM golang:1.21-alpine AS builder
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git
|
||||
RUN apk add --no-cache git gcc musl-dev
|
||||
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY backend/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o kv-tube .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o kv-tube .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache ca-certificates ffmpeg curl
|
||||
RUN apk add --no-cache ca-certificates ffmpeg curl python3 py3-pip && \
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
|
||||
chmod a+rx /usr/local/bin/yt-dlp
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/kv-tube .
|
||||
COPY data ./data
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
|
|
|||
77
backend/backend.log
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
2026/03/26 07:59:34 Database initialized successfully at ../data/kvtube.db
|
||||
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
|
||||
|
||||
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
|
||||
- using env: export GIN_MODE=release
|
||||
- using code: gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
[GIN-debug] GET /api/health --> kvtube-go/routes.SetupRouter.func2 (4 handlers)
|
||||
[GIN-debug] GET /api/search --> kvtube-go/routes.handleSearch (4 handlers)
|
||||
[GIN-debug] GET /api/trending --> kvtube-go/routes.handleTrending (4 handlers)
|
||||
[GIN-debug] GET /api/video/:id --> kvtube-go/routes.handleGetVideoInfo (4 handlers)
|
||||
[GIN-debug] GET /api/video/:id/qualities --> kvtube-go/routes.handleGetQualities (4 handlers)
|
||||
[GIN-debug] GET /api/video/:id/related --> kvtube-go/routes.handleRelatedVideos (4 handlers)
|
||||
[GIN-debug] GET /api/video/:id/comments --> kvtube-go/routes.handleComments (4 handlers)
|
||||
[GIN-debug] GET /api/video/:id/download --> kvtube-go/routes.handleDownload (4 handlers)
|
||||
[GIN-debug] GET /api/channel/info --> kvtube-go/routes.handleChannelInfo (4 handlers)
|
||||
[GIN-debug] GET /api/channel/videos --> kvtube-go/routes.handleChannelVideos (4 handlers)
|
||||
[GIN-debug] POST /api/history --> kvtube-go/routes.handlePostHistory (4 handlers)
|
||||
[GIN-debug] GET /api/history --> kvtube-go/routes.handleGetHistory (4 handlers)
|
||||
[GIN-debug] GET /api/suggestions --> kvtube-go/routes.handleGetSuggestions (4 handlers)
|
||||
[GIN-debug] POST /api/subscribe --> kvtube-go/routes.handleSubscribe (4 handlers)
|
||||
[GIN-debug] DELETE /api/subscribe --> kvtube-go/routes.handleUnsubscribe (4 handlers)
|
||||
[GIN-debug] GET /api/subscribe --> kvtube-go/routes.handleCheckSubscription (4 handlers)
|
||||
[GIN-debug] GET /api/subscriptions --> kvtube-go/routes.handleGetSubscriptions (4 handlers)
|
||||
2026/03/26 07:59:34 KV-Tube Go Backend starting on port 8080...
|
||||
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
|
||||
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
|
||||
[GIN-debug] Listening and serving HTTP on :8080
|
||||
[GIN] 2026/03/26 - 07:59:42 | 200 | 1.383093916s | ::1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=2"
|
||||
[GIN] 2026/03/26 - 08:00:04 | 200 | 2.159542ms | 127.0.0.1 | GET "/api/subscriptions"
|
||||
[GIN] 2026/03/26 - 08:00:04 | 200 | 6.37675ms | 127.0.0.1 | GET "/api/subscriptions"
|
||||
[GIN] 2026/03/26 - 08:00:06 | 200 | 2.0681615s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:06 | 200 | 2.127086416s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:08 | 200 | 1.710409125s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:08 | 200 | 1.7510695s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:09 | 200 | 1.762290709s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:10 | 200 | 1.843944666s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:11 | 200 | 1.952155291s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:11 | 200 | 1.786566833s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:13 | 200 | 1.371072416s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:13 | 200 | 1.403493s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:16 | 200 | 1.530959ms | 127.0.0.1 | GET "/api/subscriptions"
|
||||
[GIN] 2026/03/26 - 08:00:17 | 200 | 4.158150916s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:17 | 200 | 4.167733833s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:19 | 200 | 3.131014s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:21 | 200 | 1.744236s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:22 | 200 | 1.842939625s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:24 | 200 | 1.716655791s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:26 | 200 | 1.886865792s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:28 | 200 | 2.076937541s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:30 | 200 | 1.39052025s | 127.0.0.1 | GET "/api/channel/videos?id=UCuAXFkgsw1L7xaCfnd5JJOw&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:50 | 200 | 1.3275ms | 127.0.0.1 | GET "/api/subscriptions"
|
||||
[GIN] 2026/03/26 - 08:00:51 | 200 | 1.774724625s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:53 | 200 | 1.141853916s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:54 | 200 | 1.30642975s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:55 | 200 | 1.333133042s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:56 | 200 | 1.002488958s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:57 | 200 | 1.093311292s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25"
|
||||
[GIN] 2026/03/26 - 08:00:59 | 200 | 1.127070708s | 127.0.0.1 | GET "/api/channel/videos?id=UCuAXFkgsw1L7xaCfnd5JJOw&limit=25"
|
||||
[GIN] 2026/03/26 - 08:01:05 | 200 | 3.016874625s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM"
|
||||
2026/03/26 08:01:05 GetVideoInfo error: json: cannot unmarshal string into Go value of type services.YtDlpEntry
|
||||
[GIN] 2026/03/26 - 08:01:05 | 500 | 6.829375ms | 127.0.0.1 | GET "/api/video/X8dM9elNhAM"
|
||||
[GIN] 2026/03/26 - 08:01:07 | 200 | 1.475739208s | 127.0.0.1 | GET "/api/search?q=ERIK%20H%C6%B0%C6%A1ng%20%E2%80%99B%E1%BA%AFc%20(Kim)%20Bling%E2%80%99%20mix%20compilation&limit=20"
|
||||
[GIN] 2026/03/26 - 08:01:07 | 200 | 1.595221875s | 127.0.0.1 | GET "/api/search?q=ERIK%20Official%20ERIK%20H%C6%B0%C6%A1ng%20%E2%80%99B%E1%BA%AFc%20(Kim)%20Bling%E2%80%99&limit=20"
|
||||
[GIN] 2026/03/26 - 08:01:07 | 200 | 2.286978084s | 127.0.0.1 | GET "/api/search?q=music%20mix%20compilation&limit=20"
|
||||
[GIN] 2026/03/26 - 08:01:09 | 200 | 2.543206084s | 127.0.0.1 | GET "/api/search?q=music%20popular&limit=20"
|
||||
[GIN] 2026/03/26 - 08:01:10 | 200 | 4.544714333s | 127.0.0.1 | GET "/api/search?q=%20music&limit=20"
|
||||
[GIN] 2026/03/26 - 08:01:14 | 200 | 4.508561208s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50"
|
||||
[GIN] 2026/03/26 - 08:01:18 | 200 | 4.418404958s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50"
|
||||
[GIN] 2026/03/26 - 08:02:59 | 200 | 68.458µs | 127.0.0.1 | GET "/api/health"
|
||||
2026/03/26 08:03:12 GetVideoInfo error: json: cannot unmarshal string into Go value of type services.YtDlpEntry
|
||||
[GIN] 2026/03/26 - 08:03:12 | 500 | 27.501791ms | 127.0.0.1 | GET "/api/video/X8dM9elNhAM"
|
||||
[GIN] 2026/03/26 - 08:03:15 | 200 | 3.318515s | 127.0.0.1 | GET "/api/search?q=music%20mix%20compilation&limit=20"
|
||||
[GIN] 2026/03/26 - 08:03:17 | 200 | 5.183273125s | 127.0.0.1 | GET "/api/search?q=%20music&limit=20"
|
||||
[GIN] 2026/03/26 - 08:03:22 | 200 | 5.159578625s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50"
|
||||
[GIN] 2026/03/26 - 08:03:26 | 200 | 4.13119725s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50"
|
||||
[GIN] 2026/03/26 - 08:03:56 | 200 | 29.25µs | 127.0.0.1 | GET "/api/health"
|
||||
34
backend/go.mod
Executable file → Normal file
|
|
@ -1,42 +1,50 @@
|
|||
module kvtube-go
|
||||
|
||||
go 1.25.4
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
modernc.org/sqlite v1.47.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.11.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/ulule/limiter/v3 v3.11.2 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
|
|
|||
83
backend/go.sum
Executable file → Normal file
|
|
@ -5,13 +5,18 @@ github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
|
|||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
|
|
@ -22,7 +27,15 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
|||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
|
|
@ -33,21 +46,22 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
|
@ -55,33 +69,62 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA=
|
||||
github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
|
|||
BIN
backend/kv-tube
Executable file
0
backend/main.go
Executable file → Normal file
91
backend/models/cache.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CacheEntry struct {
|
||||
VideoID string
|
||||
Data []byte
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// GetCachedVideo retrieves cached video data by video ID
|
||||
func GetCachedVideo(videoID string) ([]byte, error) {
|
||||
if DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var expiresAt time.Time
|
||||
err := DB.QueryRow(
|
||||
`SELECT data, expires_at FROM video_cache WHERE video_id = ? AND expires_at > ?`,
|
||||
videoID, time.Now(),
|
||||
).Scan(&data, &expiresAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Cache query error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// SetCachedVideo stores video data in cache with TTL
|
||||
func SetCachedVideo(videoID string, data interface{}, ttlSeconds int) error {
|
||||
if DB == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(ttlSeconds) * time.Second)
|
||||
|
||||
_, err = DB.Exec(
|
||||
`INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)`,
|
||||
videoID, string(jsonData), expiresAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Cache store error: %v", err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanExpiredCache removes expired cache entries
|
||||
func CleanExpiredCache() {
|
||||
if DB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := DB.Exec(`DELETE FROM video_cache WHERE expires_at < ?`, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Cache cleanup error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows > 0 {
|
||||
log.Printf("Cleaned %d expired cache entries", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// StartCacheCleanupScheduler runs periodic cache cleanup
|
||||
func StartCacheCleanupScheduler() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
for range ticker.C {
|
||||
CleanExpiredCache()
|
||||
}
|
||||
}()
|
||||
}
|
||||
21
backend/models/database.go
Executable file → Normal file
|
|
@ -6,7 +6,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
|
@ -22,7 +22,7 @@ func InitDB() {
|
|||
}
|
||||
|
||||
dbPath := filepath.Join(dataDir, "kvtube.db")
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
|
|
@ -68,8 +68,21 @@ func InitDB() {
|
|||
}
|
||||
}
|
||||
|
||||
// Insert default user for history tracking
|
||||
_, err = db.Exec(`INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'default_user', 'password')`)
|
||||
// Create performance indexes
|
||||
indexes := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_videos_user_timestamp ON user_videos(user_id, timestamp DESC)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_videos_user_video ON user_videos(user_id, video_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_video_cache_expires ON video_cache(expires_at)`,
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
if _, err := db.Exec(idx); err != nil {
|
||||
log.Printf("Warning: Failed to create index: %v - Statement: %s", err, idx)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert default user for history tracking (password is not used for authentication)
|
||||
_, err = db.Exec(`INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'default_user', '')`)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert default user: %v", err)
|
||||
}
|
||||
|
|
|
|||
497
backend/routes/api.go
Executable file → Normal file
|
|
@ -1,11 +1,9 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
|
@ -14,12 +12,47 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getAllowedOrigins returns allowed CORS origins from environment variable or defaults
|
||||
func getAllowedOrigins() []string {
|
||||
originsEnv := os.Getenv("CORS_ALLOWED_ORIGINS")
|
||||
if originsEnv == "" {
|
||||
// Default: allow localhost for development
|
||||
return []string{
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:5011",
|
||||
"http://127.0.0.1:5011",
|
||||
}
|
||||
}
|
||||
origins := strings.Split(originsEnv, ",")
|
||||
for i := range origins {
|
||||
origins[i] = strings.TrimSpace(origins[i])
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
// isAllowedOrigin checks if the given origin is in the allowed list
|
||||
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
||||
for _, allowed := range allowedOrigins {
|
||||
if allowed == "*" || allowed == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
// CORS middleware - restrict to specific origins from environment variable
|
||||
allowedOrigins := getAllowedOrigins()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && isAllowedOrigin(origin, allowedOrigins) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
}
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
|
|
@ -27,25 +60,26 @@ func SetupRouter() *gin.Engine {
|
|||
c.Next()
|
||||
})
|
||||
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// API Routes
|
||||
// API Routes - Using yt-dlp for video operations
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// Health check
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// Video endpoints
|
||||
api.GET("/search", handleSearch)
|
||||
api.GET("/trending", handleTrending)
|
||||
api.GET("/get_stream_info", handleGetStreamInfo)
|
||||
api.GET("/download", handleDownload)
|
||||
api.GET("/transcript", handleTranscript)
|
||||
api.GET("/comments", handleComments)
|
||||
api.GET("/channel/videos", handleChannelVideos)
|
||||
api.GET("/video/:id", handleGetVideoInfo)
|
||||
api.GET("/video/:id/qualities", handleGetQualities)
|
||||
api.GET("/video/:id/related", handleRelatedVideos)
|
||||
api.GET("/video/:id/comments", handleComments)
|
||||
api.GET("/video/:id/download", handleDownload)
|
||||
|
||||
// Channel endpoints
|
||||
api.GET("/channel/info", handleChannelInfo)
|
||||
api.GET("/related", handleRelatedVideos)
|
||||
api.GET("/formats", handleGetFormats)
|
||||
api.GET("/qualities", handleGetQualities)
|
||||
api.GET("/stream", handleGetStreamByQuality)
|
||||
api.GET("/channel/videos", handleChannelVideos)
|
||||
|
||||
// History routes
|
||||
api.POST("/history", handlePostHistory)
|
||||
|
|
@ -59,11 +93,10 @@ func SetupRouter() *gin.Engine {
|
|||
api.GET("/subscriptions", handleGetSubscriptions)
|
||||
}
|
||||
|
||||
r.GET("/video_proxy", handleVideoProxy)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Video search endpoint
|
||||
func handleSearch(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
|
|
@ -71,15 +104,15 @@ func handleSearch(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
limitStr := c.Query("limit")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
results, err := services.SearchVideos(query, limit)
|
||||
if err != nil {
|
||||
log.Printf("Search error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"})
|
||||
return
|
||||
}
|
||||
|
|
@ -87,405 +120,189 @@ func handleSearch(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// Trending videos endpoint
|
||||
func handleTrending(c *gin.Context) {
|
||||
// Basic mock implementation for now
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []gin.H{
|
||||
{
|
||||
"id": "trending",
|
||||
"title": "Currently Trending",
|
||||
"icon": "fire",
|
||||
"videos": []gin.H{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
limitStr := c.Query("limit")
|
||||
limit := 20
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
func handleGetStreamInfo(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
// Use popular music search as trending
|
||||
results, err := services.SearchVideos("popular music trending", limit)
|
||||
if err != nil {
|
||||
log.Printf("Trending error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trending videos"})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := services.GetVideoInfo(videoID)
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// Get video info
|
||||
func handleGetVideoInfo(c *gin.Context) {
|
||||
videoID := c.Param("id")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
video, err := services.GetVideoInfo(videoID)
|
||||
if err != nil {
|
||||
log.Printf("GetVideoInfo Error: %v", err)
|
||||
log.Printf("GetVideoInfo error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get available qualities with audio
|
||||
qualities, audioURL, _ := services.GetVideoQualitiesWithAudio(videoID)
|
||||
|
||||
// Build quality options for frontend
|
||||
var qualityOptions []gin.H
|
||||
bestURL := info.StreamURL
|
||||
bestHeight := 0
|
||||
|
||||
for _, q := range qualities {
|
||||
proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL)
|
||||
audioProxyURL := ""
|
||||
if q.AudioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL)
|
||||
}
|
||||
qualityOptions = append(qualityOptions, gin.H{
|
||||
"label": q.Label,
|
||||
"height": q.Height,
|
||||
"url": proxyURL,
|
||||
"audio_url": audioProxyURL,
|
||||
"is_hls": q.IsHLS,
|
||||
"has_audio": q.HasAudio,
|
||||
})
|
||||
if q.Height > bestHeight {
|
||||
bestHeight = q.Height
|
||||
bestURL = q.URL
|
||||
}
|
||||
}
|
||||
|
||||
// If we found qualities, use the best one
|
||||
streamURL := info.StreamURL
|
||||
if bestURL != "" {
|
||||
streamURL = bestURL
|
||||
}
|
||||
|
||||
proxyURL := "/video_proxy?url=" + url.QueryEscape(streamURL)
|
||||
|
||||
// Get audio URL for the response
|
||||
audioProxyURL := ""
|
||||
if audioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"original_url": info.StreamURL,
|
||||
"stream_url": proxyURL,
|
||||
"audio_url": audioProxyURL,
|
||||
"title": info.Title,
|
||||
"description": info.Description,
|
||||
"uploader": info.Uploader,
|
||||
"channel_id": info.ChannelID,
|
||||
"uploader_id": info.UploaderID,
|
||||
"view_count": info.ViewCount,
|
||||
"thumbnail": info.Thumbnail,
|
||||
"related": []interface{}{},
|
||||
"subtitle_url": nil,
|
||||
"qualities": qualityOptions,
|
||||
"best_quality": bestHeight,
|
||||
})
|
||||
}
|
||||
|
||||
func handleDownload(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
formatID := c.Query("f")
|
||||
|
||||
info, err := services.GetDownloadURL(videoID, formatID)
|
||||
if err != nil {
|
||||
log.Printf("GetDownloadURL Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
func handleGetFormats(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
formats, err := services.GetVideoFormats(videoID)
|
||||
if err != nil {
|
||||
log.Printf("GetVideoFormats Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video formats"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, formats)
|
||||
c.JSON(http.StatusOK, video)
|
||||
}
|
||||
|
||||
// Get video qualities
|
||||
func handleGetQualities(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
videoID := c.Param("id")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
|
||||
if err != nil {
|
||||
log.Printf("GetVideoQualities Error: %v", err)
|
||||
log.Printf("GetQualities error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
|
||||
return
|
||||
}
|
||||
|
||||
var result []gin.H
|
||||
for _, q := range qualities {
|
||||
proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL)
|
||||
audioProxyURL := ""
|
||||
if q.AudioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL)
|
||||
}
|
||||
result = append(result, gin.H{
|
||||
"format_id": q.FormatID,
|
||||
"label": q.Label,
|
||||
"resolution": q.Resolution,
|
||||
"height": q.Height,
|
||||
"url": proxyURL,
|
||||
"audio_url": audioProxyURL,
|
||||
"is_hls": q.IsHLS,
|
||||
"vcodec": q.VCodec,
|
||||
"acodec": q.ACodec,
|
||||
"filesize": q.Filesize,
|
||||
"has_audio": q.HasAudio,
|
||||
})
|
||||
}
|
||||
|
||||
// Also return the best audio URL separately
|
||||
audioProxyURL := ""
|
||||
if audioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"qualities": result,
|
||||
"audio_url": audioProxyURL,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetStreamByQuality(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
heightStr := c.Query("q")
|
||||
height := 0
|
||||
if heightStr != "" {
|
||||
if parsed, err := strconv.Atoi(heightStr); err == nil {
|
||||
height = parsed
|
||||
}
|
||||
}
|
||||
|
||||
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
|
||||
if err != nil {
|
||||
log.Printf("GetVideoQualities Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(qualities) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "No qualities available"})
|
||||
return
|
||||
}
|
||||
|
||||
var selected *services.QualityFormat
|
||||
for i := range qualities {
|
||||
if qualities[i].Height == height {
|
||||
selected = &qualities[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selected == nil {
|
||||
selected = &qualities[0]
|
||||
}
|
||||
|
||||
proxyURL := "/video_proxy?url=" + url.QueryEscape(selected.URL)
|
||||
|
||||
audioProxyURL := ""
|
||||
if selected.AudioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(selected.AudioURL)
|
||||
} else if audioURL != "" {
|
||||
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"stream_url": proxyURL,
|
||||
"audio_url": audioProxyURL,
|
||||
"has_audio": selected.HasAudio,
|
||||
"quality": gin.H{
|
||||
"label": selected.Label,
|
||||
"height": selected.Height,
|
||||
"is_hls": selected.IsHLS,
|
||||
},
|
||||
"qualities": qualities,
|
||||
"audio_url": audioURL,
|
||||
})
|
||||
}
|
||||
|
||||
// Get related videos
|
||||
func handleRelatedVideos(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
title := c.Query("title")
|
||||
uploader := c.Query("uploader")
|
||||
|
||||
if title == "" && videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID or Title required"})
|
||||
videoID := c.Param("id")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.Query("limit")
|
||||
limit := 10
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit := 15
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
videos, err := services.GetRelatedVideos(title, uploader, limit)
|
||||
// First get video info to get title and uploader
|
||||
video, err := services.GetVideoInfo(videoID)
|
||||
if err != nil {
|
||||
log.Printf("GetRelatedVideos Error: %v", err)
|
||||
log.Printf("GetVideoInfo for related error: %v", err)
|
||||
// Fallback: search for similar content
|
||||
results, err := services.SearchVideos("music", limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, results)
|
||||
return
|
||||
}
|
||||
|
||||
related, err := services.GetRelatedVideos(video.Title, video.Uploader, limit)
|
||||
if err != nil {
|
||||
log.Printf("GetRelatedVideos error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, videos)
|
||||
}
|
||||
|
||||
func handleTranscript(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not Implemented"})
|
||||
c.JSON(http.StatusOK, related)
|
||||
}
|
||||
|
||||
// Get video comments
|
||||
func handleComments(c *gin.Context) {
|
||||
videoID := c.Query("v")
|
||||
videoID := c.Param("id")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.Query("limit")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
comments, err := services.GetComments(videoID, limit)
|
||||
if err != nil {
|
||||
log.Printf("GetComments Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get comments"})
|
||||
log.Printf("GetComments error: %v", err)
|
||||
c.JSON(http.StatusOK, []interface{}{}) // Return empty array instead of error
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, comments)
|
||||
}
|
||||
|
||||
func handleChannelInfo(c *gin.Context) {
|
||||
channelID := c.Query("id")
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"})
|
||||
// Get download URL
|
||||
func handleDownload(c *gin.Context) {
|
||||
videoID := c.Param("id")
|
||||
if videoID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := services.GetChannelInfo(channelID)
|
||||
formatID := c.Query("format")
|
||||
|
||||
downloadInfo, err := services.GetDownloadURL(videoID, formatID)
|
||||
if err != nil {
|
||||
log.Printf("GetChannelInfo Error: %v", err)
|
||||
log.Printf("GetDownloadURL error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, downloadInfo)
|
||||
}
|
||||
|
||||
// Get channel info
|
||||
func handleChannelInfo(c *gin.Context) {
|
||||
channelID := c.Query("id")
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
channelInfo, err := services.GetChannelInfo(channelID)
|
||||
if err != nil {
|
||||
log.Printf("GetChannelInfo error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel info"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
c.JSON(http.StatusOK, channelInfo)
|
||||
}
|
||||
|
||||
// Get channel videos
|
||||
func handleChannelVideos(c *gin.Context) {
|
||||
channelID := c.Query("id")
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.Query("limit")
|
||||
limit := 30
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
videos, err := services.GetChannelVideos(channelID, limit)
|
||||
if err != nil {
|
||||
log.Printf("GetChannelVideos Error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos", "details": err.Error()})
|
||||
log.Printf("GetChannelVideos error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, videos)
|
||||
}
|
||||
|
||||
func handleVideoProxy(c *gin.Context) {
|
||||
targetURL := c.Query("url")
|
||||
if targetURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No URL provided"})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
baseURL := targetURL[:strings.LastIndex(targetURL, "/")]
|
||||
|
||||
isManifest := strings.Contains(strings.ToLower(contentType), "mpegurl") ||
|
||||
strings.HasSuffix(targetURL, ".m3u8") ||
|
||||
strings.Contains(targetURL, ".m3u8")
|
||||
|
||||
if isManifest && (resp.StatusCode == 200 || resp.StatusCode == 206) {
|
||||
// Rewrite M3U8 Manifest
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
var newLines []string
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
fullURL := line
|
||||
if !strings.HasPrefix(line, "http") {
|
||||
fullURL = baseURL + "/" + line
|
||||
}
|
||||
encodedURL := url.QueryEscape(fullURL)
|
||||
newLines = append(newLines, "/video_proxy?url="+encodedURL)
|
||||
} else {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
rewrittenContent := strings.Join(newLines, "\n")
|
||||
c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewrittenContent))
|
||||
return
|
||||
}
|
||||
|
||||
// Stream binary video data
|
||||
for k, v := range resp.Header {
|
||||
logKey := strings.ToLower(k)
|
||||
if logKey != "content-encoding" && logKey != "transfer-encoding" && logKey != "connection" && !strings.HasPrefix(logKey, "access-control-") {
|
||||
c.Writer.Header()[k] = v
|
||||
}
|
||||
}
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
// History handlers
|
||||
func handlePostHistory(c *gin.Context) {
|
||||
var body struct {
|
||||
VideoID string `json:"video_id"`
|
||||
|
|
@ -526,14 +343,13 @@ func handleGetHistory(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Make the API response shape match the VideoData shape the frontend expects
|
||||
// We'll reconstruct a basic VideoData-like array for the frontend
|
||||
var results []services.VideoData
|
||||
for _, h := range history {
|
||||
results = append(results, services.VideoData{
|
||||
ID: h.ID,
|
||||
Title: h.Title,
|
||||
Thumbnail: h.Thumbnail,
|
||||
Uploader: "History", // Just a placeholder
|
||||
Uploader: "History",
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -556,6 +372,7 @@ func handleGetSuggestions(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, suggestions)
|
||||
}
|
||||
|
||||
// Subscription handlers
|
||||
func handleSubscribe(c *gin.Context) {
|
||||
var body struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
|
|
@ -623,3 +440,7 @@ func handleGetSubscriptions(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
func logPrintf(format string, v ...interface{}) {
|
||||
log.Printf(format, v...)
|
||||
}
|
||||
|
|
|
|||
31
backend/services/history.go
Executable file → Normal file
|
|
@ -2,7 +2,6 @@ package services
|
|||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"kvtube-go/models"
|
||||
)
|
||||
|
|
@ -67,30 +66,10 @@ func GetHistory(limit int) ([]HistoryVideo, error) {
|
|||
}
|
||||
|
||||
// GetSuggestions retrieves suggestions based on the user's recent history
|
||||
// NOTE: This function now returns empty results since we're using client-side YouTube API
|
||||
// The frontend should use the YouTube API directly for suggestions
|
||||
func GetSuggestions(limit int) ([]VideoData, error) {
|
||||
// 1. Get the 3 most recently watched videos to extract keywords
|
||||
history, err := GetHistory(3)
|
||||
if err != nil || len(history) == 0 {
|
||||
// Fallback to trending if no history
|
||||
return SearchVideos("trending videos", limit)
|
||||
}
|
||||
|
||||
// 2. Build a combined query string from titles
|
||||
var words []string
|
||||
for _, h := range history {
|
||||
// take first few words from title
|
||||
parts := strings.Fields(h.Title)
|
||||
for i := 0; i < len(parts) && i < 3; i++ {
|
||||
// clean up some common punctuation if needed, or just let yt-dlp handle it
|
||||
words = append(words, parts[i])
|
||||
}
|
||||
}
|
||||
|
||||
query := strings.Join(words, " ")
|
||||
if query == "" {
|
||||
query = "popular videos"
|
||||
}
|
||||
|
||||
// 3. Search using yt-dlp
|
||||
return SearchVideos(query, limit)
|
||||
// Return empty results - suggestions are now handled client-side
|
||||
// Frontend should use YouTube API for suggestions
|
||||
return []VideoData{}, nil
|
||||
}
|
||||
|
|
|
|||
0
backend/services/subscription.go
Executable file → Normal file
411
backend/services/ytdlp.go
Executable file → Normal file
|
|
@ -7,9 +7,44 @@ import (
|
|||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"kvtube-go/models"
|
||||
)
|
||||
|
||||
var ytDlpBinPath string
|
||||
|
||||
func init() {
|
||||
ytDlpBinPath = resolveYtDlpBinPath()
|
||||
}
|
||||
|
||||
func resolveYtDlpBinPath() string {
|
||||
// Check if yt-dlp is in PATH
|
||||
if _, err := exec.LookPath("yt-dlp"); err == nil {
|
||||
return "yt-dlp"
|
||||
}
|
||||
|
||||
fallbacks := []string{
|
||||
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.11/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
||||
"/usr/local/bin/yt-dlp",
|
||||
"/opt/homebrew/bin/yt-dlp",
|
||||
}
|
||||
|
||||
for _, fb := range fallbacks {
|
||||
if _, err := os.Stat(fb); err == nil {
|
||||
return fb
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return "yt-dlp"
|
||||
}
|
||||
|
||||
type VideoData struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
|
|
@ -72,6 +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)
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +125,66 @@ func sanitizeVideoData(entry YtDlpEntry) VideoData {
|
|||
}
|
||||
}
|
||||
|
||||
// extractVideoID tries to extract a YouTube video ID from yt-dlp arguments
|
||||
func extractVideoID(args []string) string {
|
||||
for _, arg := range args {
|
||||
// Look for 11-character video IDs (YouTube standard)
|
||||
if len(arg) == 11 {
|
||||
// Simple check: alphanumeric with underscore and dash
|
||||
isValid := true
|
||||
for _, c := range arg {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
|
||||
isValid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isValid {
|
||||
return arg
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from YouTube URL patterns
|
||||
if strings.Contains(arg, "youtube.com") || strings.Contains(arg, "youtu.be") {
|
||||
// Simple regex for video ID in URL
|
||||
if idx := strings.Index(arg, "v="); idx != -1 {
|
||||
id := arg[idx+2:]
|
||||
if len(id) >= 11 {
|
||||
return id[:11]
|
||||
}
|
||||
}
|
||||
// youtu.be/ID
|
||||
if idx := strings.LastIndex(arg, "/"); idx != -1 {
|
||||
id := arg[idx+1:]
|
||||
if len(id) >= 11 {
|
||||
return id[:11]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RunYtDlpCached executes yt-dlp with caching
|
||||
func RunYtDlpCached(cacheKey string, ttlSeconds int, args ...string) ([]byte, error) {
|
||||
// Try to get from cache first
|
||||
if cachedData, err := models.GetCachedVideo(cacheKey); err == nil && cachedData != nil {
|
||||
return cachedData, nil
|
||||
}
|
||||
|
||||
// Execute yt-dlp
|
||||
data, err := RunYtDlp(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store in cache (ignore cache errors)
|
||||
if cacheKey != "" {
|
||||
_ = models.SetCachedVideo(cacheKey, string(data), ttlSeconds)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// RunYtDlp securely executes yt-dlp with the given arguments and returns JSON output
|
||||
func RunYtDlp(args ...string) ([]byte, error) {
|
||||
cmdArgs := append([]string{
|
||||
|
|
@ -100,27 +196,7 @@ func RunYtDlp(args ...string) ([]byte, error) {
|
|||
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}, args...)
|
||||
|
||||
binPath := "yt-dlp"
|
||||
// Check common install paths if yt-dlp is not in PATH
|
||||
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
||||
fallbacks := []string{
|
||||
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.11/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
||||
"/usr/local/bin/yt-dlp",
|
||||
"/opt/homebrew/bin/yt-dlp",
|
||||
}
|
||||
for _, fb := range fallbacks {
|
||||
if _, err := os.Stat(fb); err == nil {
|
||||
binPath = fb
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(binPath, cmdArgs...)
|
||||
cmd := exec.Command(ytDlpBinPath, cmdArgs...)
|
||||
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
|
@ -176,14 +252,23 @@ func GetVideoInfo(videoID string) (*VideoData, error) {
|
|||
url,
|
||||
}
|
||||
|
||||
// Skip cache for now to avoid corrupted data issues
|
||||
out, err := RunYtDlp(args...)
|
||||
if err != nil {
|
||||
log.Printf("yt-dlp failed for %s: %v", videoID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log first 500 chars for debugging
|
||||
if len(out) > 0 {
|
||||
log.Printf("yt-dlp response for %s (first 200 chars): %s", videoID, string(out[:min(200, len(out))]))
|
||||
}
|
||||
|
||||
var entry YtDlpEntry
|
||||
if err := json.Unmarshal(out, &entry); err != nil {
|
||||
return nil, err
|
||||
log.Printf("JSON unmarshal error for %s: %v", videoID, err)
|
||||
log.Printf("Raw response: %s", string(out[:min(500, len(out))]))
|
||||
return nil, fmt.Errorf("failed to parse video info: %w", err)
|
||||
}
|
||||
|
||||
data := sanitizeVideoData(entry)
|
||||
|
|
@ -192,6 +277,13 @@ func GetVideoInfo(videoID string) (*VideoData, error) {
|
|||
return &data, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type QualityFormat struct {
|
||||
FormatID string `json:"format_id"`
|
||||
Label string `json:"label"`
|
||||
|
|
@ -209,44 +301,19 @@ type QualityFormat struct {
|
|||
func GetVideoQualities(videoID string) ([]QualityFormat, error) {
|
||||
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
||||
|
||||
cmdArgs := append([]string{
|
||||
cmdArgs := []string{
|
||||
"--dump-json",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
"--force-ipv4",
|
||||
"--no-playlist",
|
||||
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}, url)
|
||||
|
||||
binPath := "yt-dlp"
|
||||
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
||||
fallbacks := []string{
|
||||
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
||||
"/usr/local/bin/yt-dlp",
|
||||
"/opt/homebrew/bin/yt-dlp",
|
||||
"/config/.local/bin/yt-dlp",
|
||||
}
|
||||
for _, fb := range fallbacks {
|
||||
if _, err := os.Stat(fb); err == nil {
|
||||
binPath = fb
|
||||
break
|
||||
}
|
||||
}
|
||||
url,
|
||||
}
|
||||
|
||||
cmd := exec.Command(binPath, cmdArgs...)
|
||||
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
cacheKey := "video_qualities:" + videoID
|
||||
out, err := RunYtDlpCached(cacheKey, 3600, cmdArgs...) // Cache for 1 hour
|
||||
if err != nil {
|
||||
log.Printf("yt-dlp error: %v, stderr: %s", err, stderr.String())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -266,7 +333,7 @@ func GetVideoQualities(videoID string) ([]QualityFormat, error) {
|
|||
} `json:"formats"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
||||
if err := json.Unmarshal(out, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -353,13 +420,9 @@ func GetVideoQualities(videoID string) ([]QualityFormat, error) {
|
|||
}
|
||||
|
||||
// Sort by height descending
|
||||
for i := range qualities {
|
||||
for j := i + 1; j < len(qualities); j++ {
|
||||
if qualities[j].Height > qualities[i].Height {
|
||||
qualities[i], qualities[j] = qualities[j], qualities[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(qualities, func(i, j int) bool {
|
||||
return qualities[i].Height > qualities[j].Height
|
||||
})
|
||||
|
||||
return qualities, nil
|
||||
}
|
||||
|
|
@ -461,6 +524,201 @@ func GetVideoQualitiesWithAudio(videoID string) ([]QualityFormat, string, error)
|
|||
return qualities, audioURL, nil
|
||||
}
|
||||
|
||||
// GetFullStreamData runs a single yt-dlp command to fetch all essential information at once
|
||||
// This avoids doing 3 separate slow calls for video info, qualities, and best audio.
|
||||
func GetFullStreamData(videoID string) (*VideoData, []QualityFormat, string, error) {
|
||||
urlStr := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
||||
|
||||
cmdArgs := []string{
|
||||
"--dump-json",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
"--force-ipv4",
|
||||
"--no-playlist",
|
||||
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
urlStr,
|
||||
}
|
||||
|
||||
binPath := "yt-dlp"
|
||||
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
||||
fallbacks := []string{
|
||||
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
||||
"/usr/local/bin/yt-dlp",
|
||||
"/opt/homebrew/bin/yt-dlp",
|
||||
"/config/.local/bin/yt-dlp",
|
||||
}
|
||||
for _, fb := range fallbacks {
|
||||
if _, err := os.Stat(fb); err == nil {
|
||||
binPath = fb
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(binPath, cmdArgs...)
|
||||
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("yt-dlp error in GetFullStreamData: %v, stderr: %s", err, stderr.String())
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
// Unmarshal common metadata
|
||||
var entry YtDlpEntry
|
||||
if err := json.Unmarshal(out.Bytes(), &entry); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
videoData := sanitizeVideoData(entry)
|
||||
videoData.StreamURL = entry.URL
|
||||
|
||||
// Unmarshal formats specifically
|
||||
var raw struct {
|
||||
Formats []struct {
|
||||
FormatID string `json:"format_id"`
|
||||
FormatNote string `json:"format_note"`
|
||||
Ext string `json:"ext"`
|
||||
Resolution string `json:"resolution"`
|
||||
Width interface{} `json:"width"`
|
||||
Height interface{} `json:"height"`
|
||||
URL string `json:"url"`
|
||||
ManifestURL string `json:"manifest_url"`
|
||||
VCodec string `json:"vcodec"`
|
||||
ACodec string `json:"acodec"`
|
||||
Filesize interface{} `json:"filesize"`
|
||||
ABR interface{} `json:"abr"`
|
||||
} `json:"formats"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
var qualities []QualityFormat
|
||||
seen := make(map[int]int) // height -> index in qualities
|
||||
var bestAudio string
|
||||
var bestABR float64
|
||||
|
||||
for _, f := range raw.Formats {
|
||||
// Determine if it's the best audio
|
||||
if f.VCodec == "none" && f.ACodec != "none" && f.URL != "" {
|
||||
var abr float64
|
||||
switch v := f.ABR.(type) {
|
||||
case float64:
|
||||
abr = v
|
||||
case int:
|
||||
abr = float64(v)
|
||||
}
|
||||
if bestAudio == "" || abr > bestABR {
|
||||
bestABR = abr
|
||||
bestAudio = f.URL
|
||||
}
|
||||
}
|
||||
|
||||
if f.VCodec == "none" || f.URL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var height int
|
||||
switch v := f.Height.(type) {
|
||||
case float64:
|
||||
height = int(v)
|
||||
case int:
|
||||
height = v
|
||||
}
|
||||
|
||||
if height == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hasAudio := f.ACodec != "none" && f.ACodec != ""
|
||||
|
||||
var filesize int64
|
||||
switch v := f.Filesize.(type) {
|
||||
case float64:
|
||||
filesize = int64(v)
|
||||
case int64:
|
||||
filesize = v
|
||||
}
|
||||
|
||||
isHLS := f.ManifestURL != "" || strings.Contains(f.URL, ".m3u8") || strings.Contains(f.URL, "manifest")
|
||||
|
||||
label := f.FormatNote
|
||||
if label == "" {
|
||||
switch height {
|
||||
case 2160:
|
||||
label = "4K"
|
||||
case 1440:
|
||||
label = "1440p"
|
||||
case 1080:
|
||||
label = "1080p"
|
||||
case 720:
|
||||
label = "720p"
|
||||
case 480:
|
||||
label = "480p"
|
||||
case 360:
|
||||
label = "360p"
|
||||
default:
|
||||
label = fmt.Sprintf("%dp", height)
|
||||
}
|
||||
}
|
||||
|
||||
streamURL := f.URL
|
||||
if f.ManifestURL != "" {
|
||||
streamURL = f.ManifestURL
|
||||
}
|
||||
|
||||
qf := QualityFormat{
|
||||
FormatID: f.FormatID,
|
||||
Label: label,
|
||||
Resolution: f.Resolution,
|
||||
Height: height,
|
||||
URL: streamURL,
|
||||
IsHLS: isHLS,
|
||||
VCodec: f.VCodec,
|
||||
ACodec: f.ACodec,
|
||||
Filesize: filesize,
|
||||
HasAudio: hasAudio,
|
||||
}
|
||||
|
||||
// Prefer formats with audio, otherwise just add
|
||||
if idx, exists := seen[height]; exists {
|
||||
// Replace if this one has audio and the existing one doesn't
|
||||
if hasAudio && !qualities[idx].HasAudio {
|
||||
qualities[idx] = qf
|
||||
}
|
||||
} else {
|
||||
seen[height] = len(qualities)
|
||||
qualities = append(qualities, qf)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by height descending
|
||||
for i := range qualities {
|
||||
for j := i + 1; j < len(qualities); j++ {
|
||||
if qualities[j].Height > qualities[i].Height {
|
||||
qualities[i], qualities[j] = qualities[j], qualities[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attach audio URL to qualities without audio
|
||||
for i := range qualities {
|
||||
if !qualities[i].HasAudio && bestAudio != "" {
|
||||
qualities[i].AudioURL = bestAudio
|
||||
}
|
||||
}
|
||||
|
||||
return &videoData, qualities, bestAudio, nil
|
||||
}
|
||||
|
||||
func GetStreamURLForQuality(videoID string, height int) (string, error) {
|
||||
qualities, err := GetVideoQualities(videoID)
|
||||
if err != nil {
|
||||
|
|
@ -513,7 +771,6 @@ func GetDownloadURL(videoID string, formatID string) (*DownloadInfo, error) {
|
|||
|
||||
args := []string{
|
||||
"--format", formatArgs,
|
||||
"--dump-json",
|
||||
"--no-playlist",
|
||||
url,
|
||||
}
|
||||
|
|
@ -659,7 +916,7 @@ func GetChannelInfo(channelID string) (*ChannelInfo, error) {
|
|||
return nil, fmt.Errorf("no output from yt-dlp")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(lines[0]), &raw); err != nil {
|
||||
if err := json.Unmarshal(out, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -742,40 +999,18 @@ func GetComments(videoID string, limit int) ([]Comment, error) {
|
|||
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
||||
|
||||
cmdArgs := []string{
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"--dump-json",
|
||||
"--no-download",
|
||||
"--no-playlist",
|
||||
"--write-comments",
|
||||
fmt.Sprintf("--comment-limit=%d", limit),
|
||||
"--extractor-args", fmt.Sprintf("youtube:comment_sort=top;max_comments=%d", limit),
|
||||
url,
|
||||
}
|
||||
|
||||
cmdArgs = append([]string{
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}, cmdArgs...)
|
||||
|
||||
binPath := "yt-dlp"
|
||||
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
||||
fallbacks := []string{
|
||||
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
|
||||
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
||||
"/usr/local/bin/yt-dlp",
|
||||
"/opt/homebrew/bin/yt-dlp",
|
||||
"/config/.local/bin/yt-dlp",
|
||||
}
|
||||
for _, fb := range fallbacks {
|
||||
if _, err := os.Stat(fb); err == nil {
|
||||
binPath = fb
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(binPath, cmdArgs...)
|
||||
cmd := exec.Command(ytDlpBinPath, cmdArgs...)
|
||||
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
|
|
|||
0
doc/Product Requirements Document (PRD) - KV-Tube
Executable file → Normal file
53
docker-compose.forgejo.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:7.0.16
|
||||
container_name: forgejo
|
||||
environment:
|
||||
- USER_UID=1026
|
||||
- USER_GID=100
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- TZ=Asia/Ho_Chi_Minh
|
||||
- GITEA__actions__ENABLED=true
|
||||
- INSTALL_LOCK=true
|
||||
- FORGEJO__server__ROOT_URL=http://nas:3050/
|
||||
restart: always
|
||||
volumes:
|
||||
- ./forgejo-data:/data
|
||||
ports:
|
||||
- "3050:3000"
|
||||
- "2222:22"
|
||||
networks:
|
||||
- kv-tube_default
|
||||
|
||||
forgejo-runner:
|
||||
image: code.forgejo.org/forgejo/runner:latest
|
||||
container_name: forgejo_runner
|
||||
restart: always
|
||||
user: "0:0"
|
||||
privileged: true
|
||||
depends_on:
|
||||
- forgejo
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./forgejo-runner-data:/data
|
||||
entrypoint:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
apt-get update && apt-get install -y docker.io
|
||||
if [ ! -f /data/.runner ]; then
|
||||
forgejo-runner register --no-interactive \
|
||||
--instance http://forgejo:3000 \
|
||||
--token d5XKhmpu4lTR7P516juCjEes6QsI4qFvVean3zqT \
|
||||
--name synology-runner \
|
||||
--labels ubuntu-latest,ubuntu-22.04,docker:host
|
||||
fi
|
||||
forgejo-runner daemon
|
||||
environment:
|
||||
- TZ=Asia/Ho_Chi_Minh
|
||||
networks:
|
||||
- kv-tube_default
|
||||
|
||||
networks:
|
||||
kv-tube_default:
|
||||
external: true
|
||||
16
docker-compose.local.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
kv-tube-app-local:
|
||||
build: .
|
||||
container_name: kv-tube-app-local
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "5012:3000"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- KVTUBE_DATA_DIR=/app/data
|
||||
- GIN_MODE=release
|
||||
- NODE_ENV=production
|
||||
23
docker-compose.synology.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# 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"
|
||||
34
docker-compose.yml
Executable file → Normal file
|
|
@ -4,31 +4,25 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
kv-tube-backend:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube-backend:v4.0.0
|
||||
container_name: kv-tube-backend
|
||||
kv-tube:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=http://ut.khoavo.myds.me:8981/api
|
||||
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
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:8080/api/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
kv-tube-frontend:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube-frontend:v4.0.0
|
||||
container_name: kv-tube-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5011:3000"
|
||||
depends_on:
|
||||
- kv-tube-backend
|
||||
- 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"
|
||||
|
|
|
|||
0
frontend/.gitignore
vendored
Executable file → Normal file
0
frontend/README.md
Executable file → Normal file
621
frontend/app/ClientHomePage.tsx
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { searchVideosClient, getTrendingVideosClient } from './clientActions';
|
||||
import { VideoData } from './constants';
|
||||
import LoadingSpinner from './components/LoadingSpinner';
|
||||
|
||||
// Format view count
|
||||
function formatViews(views: number): string {
|
||||
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M views';
|
||||
if (views >= 1000) return (views / 1000).toFixed(0) + 'K views';
|
||||
return views === 0 ? '' : `${views} views`;
|
||||
}
|
||||
|
||||
// Get stable time ago based on video ID (deterministic, not random)
|
||||
function getStableTimeAgo(videoId: string): string {
|
||||
const times = ['2 hours ago', '5 hours ago', '1 day ago', '2 days ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago'];
|
||||
const hash = videoId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return times[hash % times.length];
|
||||
}
|
||||
|
||||
// Get fallback thumbnail URL (always works)
|
||||
function getFallbackThumbnail(videoId: string): string {
|
||||
return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
||||
}
|
||||
|
||||
// Video Card Component
|
||||
function VideoCard({ video }: { video: VideoData }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
// Use multiple thumbnail sources for fallback
|
||||
const thumbnailSources = [
|
||||
`https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`,
|
||||
`https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`,
|
||||
`https://i.ytimg.com/vi/${video.id}/sddefault.jpg`,
|
||||
`https://i.ytimg.com/vi/${video.id}/default.jpg`,
|
||||
];
|
||||
|
||||
const [currentSrcIndex, setCurrentSrcIndex] = useState(0);
|
||||
const currentSrc = thumbnailSources[currentSrcIndex];
|
||||
|
||||
const handleError = () => {
|
||||
if (currentSrcIndex < thumbnailSources.length - 1) {
|
||||
setCurrentSrcIndex(prev => prev + 1);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/watch?v=${video.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
{/* Thumbnail */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
aspectRatio: '16/9',
|
||||
marginBottom: '12px',
|
||||
backgroundColor: '#272727',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{!imgLoaded && !imgError && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: '#272727',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<LoadingSpinner size="small" color="white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!imgError ? (
|
||||
<img
|
||||
src={currentSrc}
|
||||
alt={video.title}
|
||||
onError={handleError}
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: imgLoaded ? 'block' : 'none',
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#333',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#666',
|
||||
}}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration badge */}
|
||||
{video.duration && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '8px',
|
||||
right: '8px',
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
color: '#fff',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{video.duration}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
transition: 'background-color 0.2s',
|
||||
cursor: 'pointer',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Video Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Title - max 2 lines */}
|
||||
<h3 style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
margin: '0 0 4px 0',
|
||||
lineHeight: '1.4',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
color: 'var(--yt-text-primary)',
|
||||
}}>
|
||||
{video.title}
|
||||
</h3>
|
||||
|
||||
{/* Channel name */}
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
marginBottom: '2px',
|
||||
}}>
|
||||
{video.uploader}
|
||||
</div>
|
||||
|
||||
{/* Views and time */}
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{(video.view_count ?? 0) > 0 && <span>{formatViews(video.view_count ?? 0)}</span>}
|
||||
{(video.view_count ?? 0) > 0 && <span>•</span>}
|
||||
<span>{video.upload_date || video.publishedAt || getStableTimeAgo(video.id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Category Pills Component
|
||||
function CategoryPills({
|
||||
categories,
|
||||
currentCategory,
|
||||
onCategoryChange
|
||||
}: {
|
||||
categories: string[];
|
||||
currentCategory: string;
|
||||
onCategoryChange: (category: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
overflowX: 'auto',
|
||||
padding: '16px 0',
|
||||
borderBottom: '1px solid var(--yt-border)',
|
||||
marginBottom: '24px',
|
||||
msOverflowStyle: 'none',
|
||||
scrollbarWidth: 'none',
|
||||
}}>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onCategoryChange(category)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: currentCategory === category ? 'var(--yt-text-primary)' : 'var(--yt-hover)',
|
||||
color: currentCategory === category ? 'var(--yt-background)' : 'var(--yt-text-primary)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '500',
|
||||
fontSize: '14px',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentCategory !== category) {
|
||||
(e.target as HTMLElement).style.backgroundColor = 'var(--yt-active)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentCategory !== category) {
|
||||
(e.target as HTMLElement).style.backgroundColor = 'var(--yt-hover)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading Skeleton
|
||||
function VideoSkeleton() {
|
||||
return (
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
aspectRatio: '16/9',
|
||||
backgroundColor: '#272727',
|
||||
borderRadius: '12px',
|
||||
marginBottom: '12px',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}} />
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#272727',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
height: '14px',
|
||||
backgroundColor: '#272727',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
width: '90%',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}} />
|
||||
<div style={{
|
||||
height: '12px',
|
||||
backgroundColor: '#272727',
|
||||
borderRadius: '4px',
|
||||
width: '60%',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get region from cookie
|
||||
function getRegionFromCookie(): string {
|
||||
if (typeof document === 'undefined') return 'VN';
|
||||
const match = document.cookie.match(/(?:^|; )region=([^;]*)/);
|
||||
return match ? decodeURIComponent(match[1]) : 'VN';
|
||||
}
|
||||
|
||||
// Check if thumbnail URL is valid (not a 404 placeholder)
|
||||
function isValidThumbnail(thumbnail: string | undefined): boolean {
|
||||
if (!thumbnail) return false;
|
||||
// YouTube default thumbnails that are usually available
|
||||
const validPatterns = [
|
||||
'i.ytimg.com/vi/',
|
||||
'i.ytimg.com/vi_webp/',
|
||||
];
|
||||
return validPatterns.some(pattern => thumbnail.includes(pattern));
|
||||
}
|
||||
|
||||
export default function ClientHomePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const categoryParam = searchParams.get('category') || 'All';
|
||||
const [videos, setVideos] = useState<VideoData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [currentCategory, setCurrentCategory] = useState(categoryParam);
|
||||
const [page, setPage] = useState(1);
|
||||
const [regionCode, setRegionCode] = useState('VN');
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
// Use refs to track state for the observer callback
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadingRef = useRef(true);
|
||||
const hasMoreRef = useRef(true);
|
||||
const pageRef = useRef(1);
|
||||
|
||||
useEffect(() => { loadingMoreRef.current = loadingMore; }, [loadingMore]);
|
||||
useEffect(() => { loadingRef.current = loading; }, [loading]);
|
||||
useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]);
|
||||
useEffect(() => { pageRef.current = page; }, [page]);
|
||||
|
||||
const categories = ['All', 'Trending', 'Music', 'Gaming', 'News', 'Sports', 'Live', 'New'];
|
||||
|
||||
// Region mapping for YouTube API
|
||||
const REGION_MAP: Record<string, string> = {
|
||||
'VN': 'Vietnam',
|
||||
'US': 'United States',
|
||||
'JP': 'Japan',
|
||||
'KR': 'South Korea',
|
||||
'IN': 'India',
|
||||
'GB': 'United Kingdom',
|
||||
'GLOBAL': '',
|
||||
};
|
||||
|
||||
// Initialize region from cookie
|
||||
useEffect(() => {
|
||||
const region = getRegionFromCookie();
|
||||
setRegionCode(region);
|
||||
}, []);
|
||||
|
||||
// Load videos when category or region changes
|
||||
useEffect(() => {
|
||||
loadVideos(currentCategory, 1);
|
||||
}, [currentCategory, regionCode]);
|
||||
|
||||
// Listen for region changes
|
||||
useEffect(() => {
|
||||
const checkRegionChange = () => {
|
||||
const newRegion = getRegionFromCookie();
|
||||
setRegionCode(prev => {
|
||||
if (newRegion !== prev) {
|
||||
return newRegion;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Listen for custom event from RegionSelector
|
||||
const handleRegionChange = (e: CustomEvent) => {
|
||||
if (e.detail?.region) {
|
||||
setRegionCode(e.detail.region);
|
||||
}
|
||||
};
|
||||
|
||||
// Check when tab becomes visible
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkRegionChange();
|
||||
}
|
||||
};
|
||||
|
||||
// Check when window gets focus
|
||||
const handleFocus = () => {
|
||||
checkRegionChange();
|
||||
};
|
||||
|
||||
window.addEventListener('regionchange', handleRegionChange as EventListener);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
// Also poll every 3 seconds as backup
|
||||
const interval = setInterval(checkRegionChange, 3000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('regionchange', handleRegionChange as EventListener);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []); // Run once on mount
|
||||
|
||||
const loadVideos = async (category: string, pageNum: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
let results: VideoData[] = [];
|
||||
const regionLabel = REGION_MAP[regionCode] || '';
|
||||
const regionSuffix = regionLabel ? ` ${regionLabel}` : '';
|
||||
|
||||
// All categories use region-specific search
|
||||
if (category === 'Trending') {
|
||||
results = await getTrendingVideosClient(regionCode, 30);
|
||||
} else if (category === 'All') {
|
||||
// Use region-specific trending for "All"
|
||||
results = await getTrendingVideosClient(regionCode, 30);
|
||||
} else {
|
||||
// Category-specific search with region
|
||||
const query = `${category}${regionSuffix}`;
|
||||
results = await searchVideosClient(query, 30);
|
||||
}
|
||||
|
||||
// Remove duplicates and filter out videos without thumbnails
|
||||
const uniqueResults = results.filter((video, index, self) => {
|
||||
const isUnique = index === self.findIndex(v => v.id === video.id);
|
||||
const hasThumbnail = isValidThumbnail(video.thumbnail);
|
||||
return isUnique && hasThumbnail;
|
||||
});
|
||||
|
||||
setVideos(uniqueResults);
|
||||
setPage(pageNum);
|
||||
setHasMore(true);
|
||||
hasMoreRef.current = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load videos:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setCurrentCategory(category);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('category', category);
|
||||
window.history.pushState({}, '', url);
|
||||
};
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMoreRef.current || loadingRef.current || !hasMoreRef.current) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
const nextPage = pageRef.current + 1;
|
||||
|
||||
try {
|
||||
const regionLabel = REGION_MAP[regionCode] || '';
|
||||
const regionSuffix = regionLabel ? ` ${regionLabel}` : '';
|
||||
|
||||
// Generate varied search queries - ALL include region
|
||||
const searchVariations = [
|
||||
`trending${regionSuffix}`,
|
||||
`popular videos${regionSuffix}`,
|
||||
`viral 2026${regionSuffix}`,
|
||||
`music${regionSuffix}`,
|
||||
`entertainment${regionSuffix}`,
|
||||
`gaming${regionSuffix}`,
|
||||
`funny${regionSuffix}`,
|
||||
`news${regionSuffix}`,
|
||||
`sports${regionSuffix}`,
|
||||
`new videos${regionSuffix}`,
|
||||
];
|
||||
|
||||
const queryIndex = (nextPage - 1) % searchVariations.length;
|
||||
const searchQuery = searchVariations[queryIndex];
|
||||
|
||||
// Always use search for variety - trending API returns same results
|
||||
const moreVideos = await searchVideosClient(searchQuery, 30);
|
||||
|
||||
// Remove duplicates and filter out videos without thumbnails
|
||||
setVideos(prev => {
|
||||
const existingIds = new Set(prev.map(v => v.id));
|
||||
const uniqueNewVideos = moreVideos.filter(v =>
|
||||
!existingIds.has(v.id) && isValidThumbnail(v.thumbnail)
|
||||
);
|
||||
|
||||
// If no new videos after filtering, stop infinite scroll
|
||||
if (uniqueNewVideos.length < 3) {
|
||||
setHasMore(false);
|
||||
hasMoreRef.current = false;
|
||||
}
|
||||
|
||||
return [...prev, ...uniqueNewVideos];
|
||||
});
|
||||
|
||||
setPage(nextPage);
|
||||
} catch (error) {
|
||||
console.error('Failed to load more videos:', error);
|
||||
// Don't stop infinite scroll on error - allow retry on next scroll
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [currentCategory, regionCode]);
|
||||
|
||||
// Ref for the loadMore function to avoid stale closures
|
||||
const loadMoreCallbackRef = useRef(loadMore);
|
||||
useEffect(() => {
|
||||
loadMoreCallbackRef.current = loadMore;
|
||||
}, [loadMore]);
|
||||
|
||||
// Infinite scroll using Intersection Observer
|
||||
useEffect(() => {
|
||||
// Don't set up observer while loading or if no videos
|
||||
if (loading || videos.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting && !loadingMoreRef.current && !loadingRef.current && hasMoreRef.current) {
|
||||
console.log('Sentinel intersecting, loading more...');
|
||||
loadMoreCallbackRef.current();
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '600px',
|
||||
threshold: 0
|
||||
}
|
||||
);
|
||||
|
||||
// Small delay to ensure DOM is ready
|
||||
const timer = setTimeout(() => {
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
console.log('Sentinel element:', sentinel);
|
||||
if (sentinel) {
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [loading, videos.length]); // Re-run when loading finishes or videos change
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'var(--yt-background)',
|
||||
color: 'var(--yt-text-primary)',
|
||||
minHeight: '100vh',
|
||||
padding: '0 24px 24px',
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* Category Pills */}
|
||||
<CategoryPills
|
||||
categories={categories}
|
||||
currentCategory={currentCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
|
||||
{/* Video Grid */}
|
||||
{loading ? (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gap: '0 24px',
|
||||
}}>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<VideoSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gap: '0 24px',
|
||||
}}>
|
||||
{videos.map((video) => (
|
||||
<VideoCard key={video.id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scroll Sentinel for Infinite Scroll */}
|
||||
<div id="scroll-sentinel" style={{ height: '100px', width: '100%' }} />
|
||||
|
||||
{/* Loading More Indicator */}
|
||||
{loadingMore && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 0',
|
||||
}}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of Results */}
|
||||
{!hasMore && videos.length > 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '48px 0',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
You've reached the end
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{videos.length === 0 && !loading && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '400px',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
}}>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style={{ marginBottom: '16px', opacity: 0.5 }}>
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '8px' }}>No videos found</h3>
|
||||
<p style={{ fontSize: '14px' }}>Try selecting a different category</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Animations */}
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
frontend/app/actions.ts
Executable file → Normal file
|
|
@ -36,7 +36,30 @@ export async function getSuggestedVideos(limit: number = 20): Promise<VideoData[
|
|||
}
|
||||
}
|
||||
|
||||
export async function fetchMoreVideos(currentCategory: string, regionLabel: string, page: number): Promise<VideoData[]> {
|
||||
export async function getRelatedVideos(videoId: string, limit: number = 10): Promise<VideoData[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/related?video_id=${encodeURIComponent(videoId)}&limit=${limit}`, { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<VideoData[]>;
|
||||
} catch (e) {
|
||||
console.error("Failed to get related videos:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentHistory(): Promise<VideoData | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/history?limit=1`, { cache: 'no-store' });
|
||||
if (!res.ok) return null;
|
||||
const history: VideoData[] = await res.json();
|
||||
return history.length > 0 ? history[0] : null;
|
||||
} catch (e) {
|
||||
console.error("Failed to get recent history:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMoreVideos(currentCategory: string, regionLabel: string, page: number, contextVideoId?: string): Promise<VideoData[]> {
|
||||
const isAllCategory = currentCategory === 'All';
|
||||
let newVideos: VideoData[] = [];
|
||||
|
||||
|
|
@ -45,28 +68,97 @@ export async function fetchMoreVideos(currentCategory: string, regionLabel: stri
|
|||
const modifier = page < pageModifiers.length ? pageModifiers[page] : `page ${page}`;
|
||||
|
||||
if (isAllCategory) {
|
||||
const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => {
|
||||
const q = addRegion(sec.query, regionLabel) + " " + modifier;
|
||||
// Fetch fewer items per section on subsequent pages to mitigate loading times
|
||||
return await getSearchVideos(q, 5);
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
const recentVideo = await getRecentHistory();
|
||||
if (recentVideo) {
|
||||
const promises = [
|
||||
getSearchVideos(addRegion("recommended for you", regionLabel) + " " + modifier, 8),
|
||||
getSearchVideos(addRegion(recentVideo.title, regionLabel) + " " + modifier, 8),
|
||||
getSearchVideos(addRegion("trending", regionLabel) + " " + modifier, 4)
|
||||
];
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Interleave the results
|
||||
const maxLen = Math.max(...results.map(arr => arr.length));
|
||||
const interleavedList: VideoData[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const interleavedList: VideoData[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
let sIdx = 0, rIdx = 0, tIdx = 0;
|
||||
const suggestedRes = results[0];
|
||||
const relatedRes = results[1];
|
||||
const trendingRes = results[2];
|
||||
|
||||
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);
|
||||
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); }
|
||||
}
|
||||
}
|
||||
newVideos = interleavedList;
|
||||
} else {
|
||||
const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => {
|
||||
const q = addRegion(sec.query, regionLabel) + " " + modifier;
|
||||
return await getSearchVideos(q, 5);
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newVideos = interleavedList;
|
||||
}
|
||||
} else if (currentCategory === 'WatchRelated' && contextVideoId) {
|
||||
// Mock infinite pagination for related
|
||||
const q = addRegion("related to " + contextVideoId, regionLabel) + " " + modifier;
|
||||
newVideos = await getSearchVideos(q, 20);
|
||||
} else if (currentCategory === 'WatchForYou') {
|
||||
const q = addRegion("recommended for you", regionLabel) + " " + modifier;
|
||||
newVideos = await getSearchVideos(q, 20);
|
||||
} else if (currentCategory === 'WatchAll' && contextVideoId) {
|
||||
// Implement 40:40:20 mix logic for watch page
|
||||
const promises = [
|
||||
getSearchVideos(addRegion("recommended for you", regionLabel) + " " + modifier, 8),
|
||||
getSearchVideos(addRegion("related to " + contextVideoId, regionLabel) + " " + modifier, 8),
|
||||
getSearchVideos(addRegion("trending", regionLabel) + " " + modifier, 4)
|
||||
];
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const interleavedList: VideoData[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
let sIdx = 0, rIdx = 0, tIdx = 0;
|
||||
const suggestedRes = results[0];
|
||||
const relatedRes = results[1];
|
||||
const trendingRes = results[2];
|
||||
|
||||
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); }
|
||||
}
|
||||
}
|
||||
newVideos = interleavedList;
|
||||
} else if (currentCategory === 'Watched') {
|
||||
|
|
@ -85,3 +177,34 @@ export async function fetchMoreVideos(currentCategory: string, regionLabel: stri
|
|||
|
||||
return newVideos;
|
||||
}
|
||||
|
||||
export interface CommentData {
|
||||
id: string;
|
||||
text: string;
|
||||
author: string;
|
||||
author_id: string;
|
||||
author_thumbnail: string;
|
||||
likes: number;
|
||||
is_reply: boolean;
|
||||
parent: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export async function getVideoComments(videoId: string, limit: number = 30): Promise<CommentData[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/comments?v=${videoId}&limit=${limit}`, { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
console.error('Comments API error:', res.status, res.statusText);
|
||||
return [];
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) {
|
||||
console.error('Comments API returned non-array:', data);
|
||||
return [];
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch comments:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const videoId = request.nextUrl.searchParams.get('v');
|
||||
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?v=${encodeURIComponent(videoId)}${formatId ? `&f=${encodeURIComponent(formatId)}` : ''}`;
|
||||
const res = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: data.error || 'Download failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to get download link' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const videoId = request.nextUrl.searchParams.get('v');
|
||||
|
||||
if (!videoId) {
|
||||
return NextResponse.json({ error: 'No video ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/formats?v=${encodeURIComponent(videoId)}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch formats' }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch formats' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const fileUrl = request.nextUrl.searchParams.get('url');
|
||||
|
||||
if (!fileUrl) {
|
||||
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(fileUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch file' }, { status: res.status });
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') || 'application/octet-stream';
|
||||
const contentLength = res.headers.get('content-length');
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': contentType,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
};
|
||||
|
||||
if (contentLength) {
|
||||
headers['Content-Length'] = contentLength;
|
||||
}
|
||||
|
||||
return new NextResponse(res.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(decodeURIComponent(url), {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://www.youtube.com/',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 });
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') || 'video/mp4';
|
||||
const contentLength = res.headers.get('content-length');
|
||||
|
||||
const headers = new Headers({
|
||||
'Content-Type': contentType,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
});
|
||||
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
return new NextResponse(res.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Proxy failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
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(`http://127.0.0.1:8080/api/get_stream_info?v=${videoId}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const streamUrl = data.original_url || data.stream_url;
|
||||
const proxyUrl = streamUrl ? `/api/proxy-stream?url=${encodeURIComponent(streamUrl)}` : null;
|
||||
|
||||
return NextResponse.json({
|
||||
streamUrl: proxyUrl,
|
||||
title: data.title,
|
||||
thumbnail: data.thumbnail
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch stream' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const channelId = request.nextUrl.searchParams.get('channel_id');
|
||||
|
||||
if (!channelId) {
|
||||
return NextResponse.json({ error: 'No channel ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ subscribed: false });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json({ subscribed: data.subscribed || false });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ subscribed: false });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { channel_id, channel_name } = 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,
|
||||
}),
|
||||
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 });
|
||||
}
|
||||
}
|
||||
6
frontend/app/channel/[id]/page.tsx
Executable file → Normal file
|
|
@ -27,9 +27,11 @@ function formatSubscribers(count: number): string {
|
|||
|
||||
// We no longer need getAvatarColor as we now use the global --yt-avatar-bg
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api';
|
||||
|
||||
async function getChannelInfo(id: string) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:8080/api/channel/info?id=${id}`, { cache: 'no-store' });
|
||||
const res = await fetch(`${API_BASE}/channel/info?id=${id}`, { cache: 'no-store' });
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<ChannelInfo>;
|
||||
} catch (e) {
|
||||
|
|
@ -40,7 +42,7 @@ async function getChannelInfo(id: string) {
|
|||
|
||||
async function getChannelVideos(id: string) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:8080/api/channel/videos?id=${id}&limit=30`, { cache: 'no-store' });
|
||||
const res = await fetch(`${API_BASE}/channel/videos?id=${id}&limit=30`, { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<VideoData[]>;
|
||||
} catch (e) {
|
||||
|
|
|
|||
271
frontend/app/clientActions.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
'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);
|
||||
}
|
||||
70
frontend/app/components/HamburgerMenu.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary, MdClose } from 'react-icons/md';
|
||||
import { useSidebar } from '../context/SidebarContext';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function HamburgerMenu() {
|
||||
const pathname = usePathname();
|
||||
const { isMobileMenuOpen, closeMobileMenu, isSidebarOpen, openSidebar } = useSidebar();
|
||||
|
||||
const navItems = [
|
||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||
];
|
||||
|
||||
// Close menu on route change
|
||||
useEffect(() => {
|
||||
closeMobileMenu();
|
||||
}, [pathname, closeMobileMenu]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`drawer-backdrop ${isMobileMenuOpen ? 'open' : ''}`}
|
||||
onClick={closeMobileMenu}
|
||||
/>
|
||||
|
||||
{/* Menu Drawer */}
|
||||
<div className={`hamburger-drawer ${isMobileMenuOpen ? 'open' : ''}`}>
|
||||
<div className="drawer-header">
|
||||
<button className="yt-icon-btn" onClick={closeMobileMenu} title="Close Menu">
|
||||
<MdClose size={24} />
|
||||
</button>
|
||||
<Link href="/" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: '12px' }} onClick={closeMobileMenu}>
|
||||
<span style={{ fontSize: '18px', fontWeight: '700', letterSpacing: '-0.5px', fontFamily: 'YouTube Sans, Roboto, Arial, sans-serif' }}>KV-Tube</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="drawer-content">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.path}
|
||||
className={`drawer-nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<div className="drawer-nav-icon">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="drawer-nav-label">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="drawer-divider" />
|
||||
<div style={{ padding: '16px 24px', fontSize: '13px', color: 'var(--yt-text-secondary)' }}>
|
||||
Made with ♡ locally
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
frontend/app/components/Header.tsx
Executable file → Normal file
|
|
@ -3,9 +3,10 @@
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack } from 'react-icons/io5';
|
||||
import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack, IoMenuOutline } from 'react-icons/io5';
|
||||
import RegionSelector from './RegionSelector';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useSidebar } from '../context/SidebarContext';
|
||||
|
||||
export default function Header() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
|
@ -15,6 +16,7 @@ export default function Header() {
|
|||
const mobileInputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleSidebar, toggleMobileMenu } = useSidebar();
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -37,6 +39,12 @@ export default function Header() {
|
|||
<>
|
||||
{/* Left */}
|
||||
<div className="yt-header-left">
|
||||
<button className="yt-icon-btn hamburger-btn" onClick={() => {
|
||||
toggleSidebar();
|
||||
toggleMobileMenu();
|
||||
}} title="Menu">
|
||||
<IoMenuOutline size={22} />
|
||||
</button>
|
||||
<Link href="/" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: '12px' }}>
|
||||
<span style={{ fontSize: '18px', fontWeight: '700', letterSpacing: '-0.5px', fontFamily: 'YouTube Sans, Roboto, Arial, sans-serif' }} className="hidden-mobile">KV-Tube</span>
|
||||
</Link>
|
||||
|
|
|
|||
13
frontend/app/components/HeaderDebug.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
|
||||
export default function HeaderDebug() {
|
||||
console.log('HeaderDebug rendered');
|
||||
return (
|
||||
<header style={{ height: 56, backgroundColor: 'blue', position: 'fixed', top: 0, left: 0, right: 0, zIndex: 500, display: 'flex', alignItems: 'center', padding: '0 16px' }}>
|
||||
<button style={{ background: 'red', width: 40, height: 40, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
☰
|
||||
</button>
|
||||
<span style={{ color: 'white', marginLeft: 12 }}>KV-Tube Debug</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
19
frontend/app/components/InfiniteVideoGrid.tsx
Executable file → Normal file
|
|
@ -4,14 +4,16 @@ 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[];
|
||||
currentCategory: string;
|
||||
regionLabel: string;
|
||||
contextVideoId?: string;
|
||||
}
|
||||
|
||||
export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel }: Props) {
|
||||
export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel, contextVideoId }: Props) {
|
||||
const [videos, setVideos] = useState<VideoData[]>(initialVideos);
|
||||
const [page, setPage] = useState(2);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -30,7 +32,7 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi
|
|||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const newVideos = await fetchMoreVideos(currentCategory, regionLabel, page);
|
||||
const newVideos = await fetchMoreVideos(currentCategory, regionLabel, page, contextVideoId);
|
||||
if (newVideos.length === 0) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
|
|
@ -55,7 +57,7 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi
|
|||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentCategory, regionLabel, page, isLoading, hasMore]);
|
||||
}, [currentCategory, regionLabel, page, isLoading, hasMore, contextVideoId]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
|
|
@ -99,16 +101,7 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi
|
|||
|
||||
{hasMore && (
|
||||
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{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>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
75
frontend/app/components/LoadingSpinner.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use client';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
fullScreen?: boolean;
|
||||
text?: string;
|
||||
color?: 'primary' | 'white';
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
small: { spinner: 24, border: 2 },
|
||||
medium: { spinner: 36, border: 3 },
|
||||
large: { spinner: 48, border: 4 },
|
||||
};
|
||||
|
||||
export default function LoadingSpinner({
|
||||
size = 'medium',
|
||||
fullScreen = false,
|
||||
text,
|
||||
color = 'primary'
|
||||
}: LoadingSpinnerProps) {
|
||||
const { spinner, border } = sizeMap[size];
|
||||
|
||||
const spinnerColor = color === 'white' ? '#fff' : 'var(--yt-text-primary)';
|
||||
const borderColor = color === 'white' ? 'rgba(255,255,255,0.2)' : 'var(--yt-border)';
|
||||
|
||||
const content = (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${spinner}px`,
|
||||
height: `${spinner}px`,
|
||||
border: `${border}px solid ${borderColor}`,
|
||||
borderTop: `${border}px solid ${spinnerColor}`,
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
{text && (
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
<style jsx>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
backgroundColor: 'var(--yt-background)',
|
||||
}}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
14
frontend/app/components/MainContent.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { useSidebar } from '../context/SidebarContext';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function MainContent({ children }: { children: ReactNode }) {
|
||||
const { isSidebarOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
<main className={`yt-main-content ${isSidebarOpen ? 'sidebar-open' : ''}`}>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
4
frontend/app/components/MobileNav.tsx
Executable file → Normal file
|
|
@ -10,8 +10,8 @@ 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: 'Subscriptions', path: '/feed/subscriptions' },
|
||||
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||
];
|
||||
|
||||
|
|
|
|||
2
frontend/app/components/RegionSelector.tsx
Executable file → Normal file
|
|
@ -48,6 +48,8 @@ export default function RegionSelector() {
|
|||
setSelected(code);
|
||||
setRegionCookie(code);
|
||||
setIsOpen(false);
|
||||
// Dispatch custom event for immediate notification
|
||||
window.dispatchEvent(new CustomEvent('regionchange', { detail: { region: code } }));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
|
|
|
|||
15
frontend/app/components/Sidebar.tsx
Executable file → Normal file
|
|
@ -4,19 +4,24 @@ import Link from 'next/link';
|
|||
import { usePathname } from 'next/navigation';
|
||||
import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md';
|
||||
import { SiYoutubeshorts } from 'react-icons/si';
|
||||
import { useSidebar } from '../context/SidebarContext';
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isSidebarOpen } = useSidebar();
|
||||
|
||||
const navItems = [
|
||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||
{ icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
||||
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="yt-sidebar-mini">
|
||||
<aside
|
||||
className={`yt-sidebar-mini ${isSidebarOpen ? 'sidebar-open' : 'sidebar-collapsed'}`}
|
||||
style={{ transition: 'transform 0.3s ease, width 0.3s ease, opacity 0.3s ease' }}
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.path;
|
||||
return (
|
||||
|
|
@ -30,15 +35,15 @@ export default function Sidebar() {
|
|||
justifyContent: 'center',
|
||||
padding: '16px 0 14px 0',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: isActive ? 'var(--yt-hover)' : 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
marginBottom: '4px',
|
||||
transition: 'var(--yt-transition)',
|
||||
gap: '4px',
|
||||
position: 'relative',
|
||||
width: '100%'
|
||||
}}
|
||||
className="yt-sidebar-item"
|
||||
>
|
||||
{isActive && <div className="sidebar-active-indicator" />}
|
||||
<div style={{ color: 'var(--yt-text-primary)', transition: 'transform 0.15s ease' }}>
|
||||
{item.icon}
|
||||
</div>
|
||||
|
|
|
|||
1
frontend/app/components/SubscribeButton.tsx
Executable file → Normal file
|
|
@ -41,6 +41,7 @@ export default function SubscribeButton({ channelId, channelName, initialSubscri
|
|||
body: JSON.stringify({
|
||||
channel_id: channelId,
|
||||
channel_name: channelName || channelId,
|
||||
channel_avatar: channelName ? channelName[0].toUpperCase() : '?',
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
|
|
|
|||
94
frontend/app/components/VideoCard.tsx
Executable file → Normal file
|
|
@ -1,15 +1,10 @@
|
|||
import Link from 'next/link';
|
||||
'use client';
|
||||
|
||||
interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
channel_id?: string;
|
||||
thumbnail: string;
|
||||
view_count: number;
|
||||
duration: string;
|
||||
uploaded_date?: string;
|
||||
}
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { VideoData } from '@/app/constants';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
function formatViews(views: number): string {
|
||||
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
||||
|
|
@ -17,36 +12,81 @@ function formatViews(views: number): string {
|
|||
return views.toString();
|
||||
}
|
||||
|
||||
function getRelativeTime(id: string): string {
|
||||
function getStableRelativeTime(id: string): string {
|
||||
const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago'];
|
||||
const index = (id.charCodeAt(0) || 0) % times.length;
|
||||
return times[index];
|
||||
const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return times[hash % times.length];
|
||||
}
|
||||
|
||||
export default function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
|
||||
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
|
||||
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 [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;
|
||||
|
||||
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (img.src !== DEFAULT_THUMBNAIL) {
|
||||
img.src = DEFAULT_THUMBNAIL;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', marginBottom: '12px' }} className="videocard-container">
|
||||
<Link href={`/watch?v=${video.id}`} style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
<Link
|
||||
href={destination}
|
||||
onClick={() => setIsNavigating(true)}
|
||||
style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}
|
||||
>
|
||||
<Image
|
||||
src={thumbnailSrc}
|
||||
alt={video.title}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
style={{ objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
|
||||
className="videocard-thumb"
|
||||
priority={false}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
{video.duration && (
|
||||
{video.duration && !video.is_mix && (
|
||||
<div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
|
||||
{video.duration}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{video.is_mix && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, right: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)', color: 'white',
|
||||
padding: '4px 8px', fontSize: '12px', fontWeight: 500,
|
||||
borderTopLeftRadius: '8px', zIndex: 5,
|
||||
display: 'flex', alignItems: 'center', gap: '4px'
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M22 7H2v1h20V7zm-9 5H2v-1h11v1zm0 4H2v-1h11v1zm2 3v-8l7 4-7 4z"></path></svg>
|
||||
Mix
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNavigating && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<LoadingSpinner color="white" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', padding: '0 12px' }} className="videocard-info">
|
||||
{/* Video Info */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<Link href={`/watch?v=${video.id}`} style={{ textDecoration: 'none' }}>
|
||||
<Link href={destination} style={{ textDecoration: 'none' }}>
|
||||
<h3 className="truncate-2-lines" style={{ fontSize: '16px', fontWeight: 500, lineHeight: '22px', margin: 0, color: 'var(--yt-text-primary)', transition: 'color 0.2s' }}>
|
||||
{video.title}
|
||||
</h3>
|
||||
|
|
@ -54,15 +94,15 @@ export default function VideoCard({ video, hideChannelAvatar }: { video: VideoDa
|
|||
<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.uploader || video.channelTitle || 'Unknown'}
|
||||
</Link>
|
||||
) : (
|
||||
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', display: 'block' }}>
|
||||
{video.uploader}
|
||||
{video.uploader || video.channelTitle || 'Unknown'}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||
{formatViews(video.view_count)} views • {relativeTime}
|
||||
{formatViews(video.view_count ?? 0)} views • {relativeTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,3 +110,5 @@ export default function VideoCard({ video, hideChannelAvatar }: { video: VideoDa
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(VideoCard);
|
||||
|
|
|
|||
17
frontend/app/constants.ts
Executable file → Normal file
|
|
@ -1,13 +1,24 @@
|
|||
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
|
||||
export const API_BASE = ''; // No backend needed - using public APIs
|
||||
|
||||
export interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
thumbnail: string;
|
||||
view_count: number;
|
||||
channelTitle?: string;
|
||||
channelId?: string;
|
||||
viewCount?: string;
|
||||
publishedAt?: string;
|
||||
duration: string;
|
||||
description?: string;
|
||||
// Legacy fields for compatibility
|
||||
uploader?: string;
|
||||
uploader_id?: string;
|
||||
channel_id?: string;
|
||||
view_count?: number;
|
||||
upload_date?: string;
|
||||
avatar_url?: string;
|
||||
list_id?: string;
|
||||
is_mix?: boolean;
|
||||
}
|
||||
|
||||
export const CATEGORY_MAP: Record<string, string> = {
|
||||
|
|
|
|||
72
frontend/app/context/SidebarContext.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
interface SidebarContextType {
|
||||
isSidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
openSidebar: () => void;
|
||||
closeSidebar: () => void;
|
||||
isMobileMenuOpen: boolean;
|
||||
toggleMobileMenu: () => void;
|
||||
openMobileMenu: () => void;
|
||||
closeMobileMenu: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
// Sidebar is collapsed by default on desktop
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// Load saved preference from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('sidebarOpen');
|
||||
if (saved !== null) {
|
||||
setIsSidebarOpen(saved === 'true');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarOpen', isSidebarOpen.toString());
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
const toggleSidebar = () => setIsSidebarOpen(prev => !prev);
|
||||
const openSidebar = () => setIsSidebarOpen(true);
|
||||
const closeSidebar = () => setIsSidebarOpen(false);
|
||||
|
||||
const toggleMobileMenu = () => setIsMobileMenuOpen(prev => !prev);
|
||||
const openMobileMenu = () => setIsMobileMenuOpen(true);
|
||||
const closeMobileMenu = () => setIsMobileMenuOpen(false);
|
||||
|
||||
// Prevent body scroll when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{
|
||||
isSidebarOpen, toggleSidebar, openSidebar, closeSidebar,
|
||||
isMobileMenuOpen, toggleMobileMenu, openMobileMenu, closeMobileMenu
|
||||
}}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
0
frontend/app/context/ThemeContext.tsx
Executable file → Normal file
0
frontend/app/favicon.ico
Executable file → Normal file
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
374
frontend/app/feed/library/page.tsx
Executable file → Normal file
|
|
@ -1,4 +1,11 @@
|
|||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
interface VideoData {
|
||||
id: string;
|
||||
|
|
@ -7,6 +14,7 @@ interface VideoData {
|
|||
thumbnail: string;
|
||||
view_count: number;
|
||||
duration: string;
|
||||
uploaded_date?: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
|
|
@ -16,96 +24,268 @@ interface Subscription {
|
|||
channel_avatar: string;
|
||||
}
|
||||
|
||||
async function getHistory() {
|
||||
try {
|
||||
const res = await fetch('http://127.0.0.1:8080/api/history?limit=20', { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<VideoData[]>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getSubscriptions() {
|
||||
try {
|
||||
const res = await fetch('http://127.0.0.1:8080/api/subscriptions', { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<Subscription[]>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export default async function LibraryPage() {
|
||||
const [history, subscriptions] = await Promise.all([getHistory(), getSubscriptions()]);
|
||||
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 HistoryVideoCard({ video }: { video: VideoData }) {
|
||||
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
|
||||
const destination = `/watch?v=${video.id}`;
|
||||
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}
|
||||
/>
|
||||
{video.duration && (
|
||||
<div className="duration-badge">{video.duration}</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.uploader}
|
||||
</p>
|
||||
{video.view_count > 0 && (
|
||||
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
|
||||
{formatViews(video.view_count)} views
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionCard({ subscription }: { subscription: Subscription }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/channel/${subscription.channel_id}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'var(--yt-hover)',
|
||||
minWidth: '120px',
|
||||
transition: 'background-color 0.2s',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
className="card-hover-lift"
|
||||
>
|
||||
<div style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--yt-avatar-bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '28px',
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
}}>
|
||||
{subscription.channel_avatar || (subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?')}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--yt-text-primary)',
|
||||
textAlign: 'center',
|
||||
maxWidth: '100px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{subscription.channel_name || subscription.channel_id}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
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' })
|
||||
]);
|
||||
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* Subscriptions Section */}
|
||||
{subscriptions.length > 0 && (
|
||||
<section style={{ marginBottom: '40px' }}>
|
||||
<h2 className="section-heading" style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||
Subscriptions
|
||||
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||
Sub
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
{subscriptions.map((sub) => (
|
||||
<Link
|
||||
key={sub.channel_id}
|
||||
href={`/channel/${sub.channel_id}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'var(--yt-hover)',
|
||||
minWidth: '120px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
className="card-hover-lift"
|
||||
>
|
||||
<div style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--yt-avatar-bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '28px',
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
}}>
|
||||
{sub.channel_avatar || (sub.channel_name ? sub.channel_name[0].toUpperCase() : '?')}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--yt-text-primary)',
|
||||
textAlign: 'center',
|
||||
maxWidth: '100px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{sub.channel_name || sub.channel_id}
|
||||
</span>
|
||||
</Link>
|
||||
<SubscriptionCard key={sub.channel_id} subscription={sub} />
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Watch History Section */}
|
||||
<section>
|
||||
<h2 className="section-heading" style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||
Watch History
|
||||
</h2>
|
||||
{history.length === 0 ? (
|
||||
|
|
@ -126,59 +306,7 @@ export default async function LibraryPage() {
|
|||
gap: '16px',
|
||||
}}>
|
||||
{history.map((video) => (
|
||||
<Link
|
||||
key={video.id}
|
||||
href={`/watch?v=${video.id}`}
|
||||
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={video.thumbnail}
|
||||
alt={video.title}
|
||||
className="videocard-thumb"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
{video.duration && (
|
||||
<div className="duration-badge">{video.duration}</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.uploader}
|
||||
</p>
|
||||
{video.view_count > 0 && (
|
||||
<p style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
}}>
|
||||
{formatViews(video.view_count)} views
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<HistoryVideoCard key={video.id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
360
frontend/app/feed/subscriptions/page.tsx
Executable file → Normal file
|
|
@ -1,41 +1,43 @@
|
|||
import Link from 'next/link';
|
||||
'use client';
|
||||
|
||||
interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
channel_id: string;
|
||||
thumbnail: string;
|
||||
view_count: number;
|
||||
duration: string;
|
||||
}
|
||||
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';
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
channel_avatar: string;
|
||||
}
|
||||
|
||||
async function getSubscriptions() {
|
||||
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('http://127.0.0.1:8080/api/subscriptions', { cache: 'no-store' });
|
||||
const res = await fetch(`${API_BASE}/subscriptions`, { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<Subscription[]>;
|
||||
} catch {
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch subscriptions:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getChannelVideos(channelId: string, limit: number = 5) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:8080/api/channel/videos?id=${channelId}&limit=${limit}`, { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<VideoData[]>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const INITIAL_ROWS = 2;
|
||||
const VIDEOS_PER_ROW = 5;
|
||||
const MAX_ROWS = 5;
|
||||
|
||||
function formatViews(views: number): string {
|
||||
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
||||
|
|
@ -43,108 +45,232 @@ function formatViews(views: number): string {
|
|||
return views.toString();
|
||||
}
|
||||
|
||||
export default async function SubscriptionsPage() {
|
||||
const subscriptions = await getSubscriptions();
|
||||
function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVideos: ChannelVideos; defaultExpanded?: boolean }) {
|
||||
const { subscription, videos } = channelVideos;
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (img.src !== DEFAULT_THUMBNAIL) {
|
||||
img.src = DEFAULT_THUMBNAIL;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (videos.length === 0) return null;
|
||||
|
||||
const initialCount = INITIAL_ROWS * VIDEOS_PER_ROW;
|
||||
const maxCount = MAX_ROWS * VIDEOS_PER_ROW;
|
||||
const displayedVideos = expanded ? videos.slice(0, maxCount) : videos.slice(0, initialCount);
|
||||
const hasMore = videos.length > initialCount;
|
||||
|
||||
return (
|
||||
<section style={{ marginBottom: '32px' }}>
|
||||
<Link
|
||||
href={`/channel/${subscription.channel_id}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '16px',
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--yt-avatar-bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
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() : '?'
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', fontWeight: '500', color: 'var(--yt-text-primary)', textAlign: 'center' }}>
|
||||
{subscription.channel_name || subscription.channel_id}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gap: '16px',
|
||||
padding: '0 12px',
|
||||
}}>
|
||||
{displayedVideos.map((video) => {
|
||||
const relativeTime = video.publishedAt || video.upload_date || 'recently';
|
||||
const destination = `/watch?v=${video.id}`;
|
||||
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={video.id}
|
||||
href={destination}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className="card-hover-lift"
|
||||
>
|
||||
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={video.title}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
{video.duration && (
|
||||
<div className="duration-badge">{video.duration}</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
lineHeight: '20px',
|
||||
color: 'var(--yt-text-primary)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
margin: 0,
|
||||
}}>
|
||||
{video.title}
|
||||
</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', margin: 0 }}>
|
||||
{video.viewCount || formatViews(video.view_count || 0)} views • {relativeTime}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div style={{ padding: '16px 12px 0', textAlign: 'left' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '18px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--yt-hover)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Show less' : `Show more (${videos.length - initialCount} more)`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const [channelsVideos, setChannelsVideos] = useState<ChannelVideos[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const subs = await fetchSubscriptions();
|
||||
|
||||
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);
|
||||
|
||||
if (videos.length > 0) {
|
||||
return {
|
||||
subscription: sub,
|
||||
videos: videos,
|
||||
channelInfo: channelInfo || null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch videos for ${sub.channel_id}:`, err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const validResults = results.filter((r): r is ChannelVideos => r !== null);
|
||||
|
||||
setChannelsVideos(validResults);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch subscriptions:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
<div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const videosPerChannel = await Promise.all(
|
||||
subscriptions.map(async (sub) => ({
|
||||
subscription: sub,
|
||||
videos: await getChannelVideos(sub.channel_id, 5),
|
||||
}))
|
||||
);
|
||||
if (channelsVideos.length === 0) {
|
||||
return (
|
||||
<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: '24px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px' }}>Subscriptions</h1>
|
||||
<div style={{ padding: '12px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px', padding: '0 12px' }}>Sub</h1>
|
||||
|
||||
{videosPerChannel.map(({ subscription, videos }) => (
|
||||
<section key={subscription.channel_id} style={{ marginBottom: '32px' }}>
|
||||
<Link
|
||||
href={`/channel/${subscription.channel_id}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--yt-avatar-bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
}}>
|
||||
{subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'}
|
||||
</div>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '500' }}>{subscription.channel_name || subscription.channel_id}</h2>
|
||||
</Link>
|
||||
|
||||
{videos.length > 0 ? (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gap: '16px',
|
||||
}}>
|
||||
{videos.map((video) => (
|
||||
<Link
|
||||
key={video.id}
|
||||
href={`/watch?v=${video.id}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className="card-hover-lift"
|
||||
>
|
||||
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
{video.duration && (
|
||||
<div className="duration-badge">{video.duration}</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
lineHeight: '20px',
|
||||
color: 'var(--yt-text-primary)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{video.title}
|
||||
</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
|
||||
{formatViews(video.view_count)} views
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--yt-text-secondary)', fontSize: '14px' }}>No videos available</p>
|
||||
)}
|
||||
</section>
|
||||
{channelsVideos.map((channelData) => (
|
||||
<ChannelSection key={channelData.subscription.channel_id} channelVideos={channelData} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
315
frontend/app/globals.css
Executable file → Normal file
|
|
@ -251,10 +251,24 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 4px;
|
||||
z-index: 400;
|
||||
z-index: 600;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Sidebar collapsed by default on desktop */
|
||||
.yt-sidebar-mini.sidebar-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.yt-sidebar-mini.sidebar-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* When sidebar is open, shift main content */
|
||||
.yt-main-content.sidebar-open {
|
||||
margin-left: var(--yt-sidebar-width-mini);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.yt-sidebar-mini {
|
||||
display: none;
|
||||
|
|
@ -263,7 +277,7 @@
|
|||
|
||||
.yt-main-content {
|
||||
margin-top: var(--yt-header-height);
|
||||
margin-left: var(--yt-sidebar-width-mini);
|
||||
margin-left: 0;
|
||||
min-height: calc(100vh - var(--yt-header-height));
|
||||
background-color: var(--yt-background);
|
||||
}
|
||||
|
|
@ -619,41 +633,237 @@ a {
|
|||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* ===== HAMBURGER MENU / DRAWER ===== */
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme='light'] .drawer-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.drawer-backdrop.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hamburger-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
background-color: var(--yt-background);
|
||||
z-index: 1100; /* Ensure it is above EVERYTHING including backdrops and players */
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.hamburger-drawer.open {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
[data-theme='light'] .hamburger-drawer.open {
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
height: var(--yt-header-height);
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--yt-border);
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.drawer-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px 0 24px;
|
||||
height: 48px;
|
||||
color: var(--yt-text-primary);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer-nav-item:hover,
|
||||
.drawer-nav-item:focus {
|
||||
background-color: var(--yt-hover);
|
||||
}
|
||||
|
||||
.drawer-nav-item.active {
|
||||
background-color: var(--yt-hover);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drawer-nav-item.active .drawer-nav-icon {
|
||||
color: var(--yt-text-primary);
|
||||
}
|
||||
|
||||
.drawer-nav-icon {
|
||||
margin-right: 24px;
|
||||
color: var(--yt-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drawer-nav-label {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.drawer-divider {
|
||||
border-top: 1px solid var(--yt-border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* ===== WATCH PAGE ===== */
|
||||
|
||||
.watch-container {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 402px;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 24px;
|
||||
max-width: 1750px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
justify-content: center;
|
||||
padding: 24px 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.watch-primary {
|
||||
flex: 1;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.watch-secondary {
|
||||
width: 402px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
max-height: calc(100vh - 104px);
|
||||
overflow-y: auto;
|
||||
padding-right: 12px;
|
||||
padding-left: 6px;
|
||||
/* Hide scrollbar for clean look */
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
.watch-container > .comments-section {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
padding-right: 16px; /* Space before sidebar */
|
||||
}
|
||||
|
||||
.watch-secondary::-webkit-scrollbar {
|
||||
display: none;
|
||||
.watch-secondary {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
width: 100%;
|
||||
padding-right: 12px; /* Breathing room for items from edge/scrollbar */
|
||||
}
|
||||
|
||||
/* Responsive Watch Layout */
|
||||
@media (max-width: 1024px) {
|
||||
.watch-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
.watch-primary, .watch-secondary, .comments-section {
|
||||
padding: 0 4px;
|
||||
}
|
||||
.watch-primary {
|
||||
order: 1;
|
||||
}
|
||||
.watch-secondary {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
order: 3;
|
||||
}
|
||||
.watch-container > .comments-section {
|
||||
order: 2;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* On mobile: show collapsed header, hide full header when not expanded */
|
||||
.comments-collapsed-header {
|
||||
display: flex !important;
|
||||
}
|
||||
.comments-section:not(:has(.comments-list.expanded)) .comments-full-header {
|
||||
display: none;
|
||||
}
|
||||
.comments-section:has(.comments-list.expanded) .comments-collapsed-header {
|
||||
display: none !important;
|
||||
}
|
||||
.comments-section:not(:has(.comments-list.expanded)) .comments-list {
|
||||
max-height: 200px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.comments-section:not(:has(.comments-list.expanded)) .comments-list::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: linear-gradient(transparent, var(--background));
|
||||
pointer-events: none;
|
||||
}
|
||||
.comments-toggle-btn {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar grid styles on large screens */
|
||||
@media (min-width: 1025px) {
|
||||
.watch-secondary {
|
||||
position: sticky;
|
||||
top: calc(var(--yt-header-height) + 24px);
|
||||
height: calc(100vh - var(--yt-header-height) - 48px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin; /* Clean scrollbar appearance */
|
||||
}
|
||||
|
||||
.watch-video-grid .video-grid-mobile {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
.watch-video-grid .videocard-container {
|
||||
flex-direction: row !important;
|
||||
gap: 8px !important;
|
||||
align-items: flex-start !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
.watch-video-grid .videocard-container > a {
|
||||
width: 168px !important;
|
||||
min-width: 168px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
.watch-video-grid .videocard-info {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.watch-video-grid h3.truncate-2-lines {
|
||||
font-size: 14px !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Player Wrapper */
|
||||
|
|
@ -1384,4 +1594,63 @@ a {
|
|||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
}@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Download dropdown mobile improvements */
|
||||
.download-dropdown {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.download-backdrop {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Format item hover effect */
|
||||
.format-item-hover:hover {
|
||||
background-color: var(--yt-hover) !important;
|
||||
}
|
||||
|
||||
/* Action button hover effect */
|
||||
.action-btn-hover:hover {
|
||||
background-color: var(--yt-active) !important;
|
||||
}
|
||||
|
||||
/* Better loading state visibility */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--yt-hover) 25%,
|
||||
var(--yt-active) 50%,
|
||||
var(--yt-hover) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
53
frontend/app/layout.tsx
Executable file → Normal file
|
|
@ -5,6 +5,8 @@ import './globals.css';
|
|||
import Header from './components/Header';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import MobileNav from './components/MobileNav';
|
||||
import HamburgerMenu from './components/HamburgerMenu';
|
||||
import MainContent from './components/MainContent';
|
||||
|
||||
const roboto = Roboto({
|
||||
weight: ['400', '500', '700'],
|
||||
|
|
@ -14,10 +16,33 @@ const roboto = Roboto({
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: 'KV-Tube',
|
||||
description: 'A pixel perfect YouTube clone',
|
||||
description: 'A modern YouTube-like video streaming platform with background playback',
|
||||
manifest: '/manifest.json',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'KV-Tube',
|
||||
startupImage: [
|
||||
{
|
||||
url: '/icons/icon-512x512.png',
|
||||
media: '(device-width: 1024px)',
|
||||
},
|
||||
],
|
||||
},
|
||||
other: {
|
||||
'mobile-web-app-capable': 'yes',
|
||||
'apple-mobile-web-app-capable': 'yes',
|
||||
'apple-mobile-web-app-status-bar-style': 'black-translucent',
|
||||
'theme-color': '#ff0000',
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
themeColor: '#000000',
|
||||
};
|
||||
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { SidebarProvider } from './context/SidebarContext';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
|
|
@ -39,15 +64,29 @@ export default function RootLayout({
|
|||
`,
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
});
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<Header />
|
||||
<Sidebar />
|
||||
<main className="yt-main-content">
|
||||
{children}
|
||||
</main>
|
||||
<MobileNav />
|
||||
<SidebarProvider>
|
||||
<Header />
|
||||
<Sidebar />
|
||||
<HamburgerMenu />
|
||||
<MainContent>
|
||||
{children}
|
||||
</MainContent>
|
||||
<MobileNav />
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
123
frontend/app/page.tsx
Executable file → Normal file
|
|
@ -1,116 +1,11 @@
|
|||
import Link from 'next/link';
|
||||
import { cookies } from 'next/headers';
|
||||
import InfiniteVideoGrid from './components/InfiniteVideoGrid';
|
||||
import {
|
||||
getSearchVideos,
|
||||
getHistoryVideos,
|
||||
getSuggestedVideos
|
||||
} from './actions';
|
||||
import {
|
||||
VideoData,
|
||||
CATEGORY_MAP,
|
||||
ALL_CATEGORY_SECTIONS,
|
||||
addRegion,
|
||||
getRandomModifier
|
||||
} from './utils';
|
||||
import { Suspense } from 'react';
|
||||
import ClientHomePage from './ClientHomePage';
|
||||
import LoadingSpinner from './components/LoadingSpinner';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const REGION_LABELS: Record<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();
|
||||
|
||||
if (isAllCategory) {
|
||||
// Fetch top 6 from each category to build a robust recommendation feed
|
||||
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);
|
||||
}
|
||||
|
||||
const categoriesList = Object.keys(CATEGORY_MAP);
|
||||
|
||||
return (
|
||||
<div style={{ paddingTop: '12px' }}>
|
||||
{/* Category Chips Scrollbar */}
|
||||
<div style={{ display: 'flex', gap: '12px', padding: '0 12px', marginBottom: '16px', overflowX: 'auto', justifyContent: 'center' }} className="chips-container hide-scrollbox">
|
||||
{categoriesList.map((cat) => {
|
||||
const isActive = cat === currentCategory;
|
||||
return (
|
||||
<Link key={cat} href={cat === 'All' ? '/' : `/?category=${encodeURIComponent(cat)}`} style={{ textDecoration: 'none' }}>
|
||||
<button
|
||||
className={`chip ${isActive ? 'active' : ''}`}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'var(--yt-transition)',
|
||||
backgroundColor: isActive ? 'var(--foreground)' : 'var(--yt-hover)',
|
||||
color: isActive ? 'var(--background)' : 'var(--yt-text-primary)'
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 12px' }} className="main-container-mobile">
|
||||
<InfiniteVideoGrid
|
||||
initialVideos={gridVideos}
|
||||
currentCategory={currentCategory}
|
||||
regionLabel={regionLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner fullScreen text="Loading videos..." />}>
|
||||
<ClientHomePage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
223
frontend/app/search/ClientSearchPage.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { searchVideosClient } from '../clientActions';
|
||||
import { VideoData } from '../constants';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
function SearchSkeleton() {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: '16px',
|
||||
}}>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{
|
||||
aspectRatio: '16/9',
|
||||
backgroundColor: 'var(--yt-hover)',
|
||||
borderRadius: '12px',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}} />
|
||||
<div style={{ display: 'flex', gap: '12px', padding: '0' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ width: '90%', height: '16px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
|
||||
<div style={{ width: '60%', height: '12px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
|
||||
<div style={{ width: '40%', height: '12px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientSearchPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.get('q') || '';
|
||||
const [videos, setVideos] = useState<VideoData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [searchPage, setSearchPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const hasMoreRef = useRef(true);
|
||||
const searchPageRef = useRef(0);
|
||||
|
||||
useEffect(() => { loadingMoreRef.current = loadingMore; }, [loadingMore]);
|
||||
useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]);
|
||||
useEffect(() => { searchPageRef.current = searchPage; }, [searchPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
performSearch(query);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const performSearch = async (q: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSearchPage(0);
|
||||
searchPageRef.current = 0;
|
||||
setHasMore(true);
|
||||
hasMoreRef.current = true;
|
||||
|
||||
const results = await searchVideosClient(q, 50);
|
||||
const uniqueResults = results.filter((video, index, self) =>
|
||||
index === self.findIndex(v => v.id === video.id)
|
||||
);
|
||||
setVideos(uniqueResults);
|
||||
setHasMore(uniqueResults.length >= 40);
|
||||
hasMoreRef.current = uniqueResults.length >= 40;
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMoreRef.current || !hasMoreRef.current || !query) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
const nextPage = searchPageRef.current + 1;
|
||||
|
||||
try {
|
||||
// Use different search variations to get more results
|
||||
const variations = [
|
||||
`${query}`,
|
||||
`${query} official`,
|
||||
`${query} video`,
|
||||
`${query} review`,
|
||||
`${query} tutorial`,
|
||||
`${query} 2026`,
|
||||
`${query} new`,
|
||||
`${query} best`,
|
||||
];
|
||||
const searchVariation = variations[nextPage % variations.length];
|
||||
|
||||
const results = await searchVideosClient(searchVariation, 50);
|
||||
|
||||
setVideos(prev => {
|
||||
const existingIds = new Set(prev.map(v => v.id));
|
||||
const uniqueNewVideos = results.filter(v => !existingIds.has(v.id));
|
||||
|
||||
// Stop loading if we get very few new videos
|
||||
if (uniqueNewVideos.length < 3) {
|
||||
setHasMore(false);
|
||||
hasMoreRef.current = false;
|
||||
}
|
||||
|
||||
return [...prev, ...uniqueNewVideos];
|
||||
});
|
||||
|
||||
setSearchPage(nextPage);
|
||||
searchPageRef.current = nextPage;
|
||||
} catch (error) {
|
||||
console.error('Failed to load more:', error);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
// Infinite scroll observer
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !loadingMoreRef.current && hasMoreRef.current) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '500px', threshold: 0.1 }
|
||||
);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (observerTarget.current) {
|
||||
observer.observe(observerTarget.current);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [loadMore]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'var(--yt-background)',
|
||||
color: 'var(--yt-text-primary)',
|
||||
minHeight: '100vh',
|
||||
padding: '0 24px 24px',
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* Results Header */}
|
||||
{query && !loading && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)' }}>
|
||||
{videos.length > 0 ? `${videos.length} results for "${query}"` : `No results for "${query}"`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Grid */}
|
||||
{loading ? (
|
||||
<SearchSkeleton />
|
||||
) : videos.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '80px 24px',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
}}>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style={{ marginBottom: '16px', opacity: 0.5 }}>
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
</svg>
|
||||
<h3 style={{ fontSize: '18px', marginBottom: '8px', color: 'var(--yt-text-primary)' }}>
|
||||
No results found
|
||||
</h3>
|
||||
<p style={{ fontSize: '14px' }}>Try different keywords or check your spelling</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: '16px',
|
||||
}}>
|
||||
{videos.map((video) => (
|
||||
<VideoCard key={video.id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loadingMore && <LoadingSpinner />}
|
||||
</div>
|
||||
|
||||
{/* End of results */}
|
||||
{!hasMore && videos.length > 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 0',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
End of results
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
frontend/app/search/page.tsx
Executable file → Normal file
|
|
@ -1,173 +1,21 @@
|
|||
export const dynamic = 'force-dynamic';
|
||||
import { Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { cookies } from 'next/headers';
|
||||
import ClientSearchPage from './ClientSearchPage';
|
||||
|
||||
interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
channel_id?: string;
|
||||
thumbnail: string;
|
||||
view_count: number;
|
||||
duration: string;
|
||||
description: string;
|
||||
avatar_url?: string;
|
||||
uploaded_date?: string;
|
||||
}
|
||||
|
||||
function formatViews(views: number): string {
|
||||
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
||||
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
|
||||
return views.toString();
|
||||
}
|
||||
|
||||
async function fetchSearchResults(query: string) {
|
||||
try {
|
||||
const res = await fetch(`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() {
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<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>
|
||||
<Suspense fallback={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#0f0f0f',
|
||||
color: '#fff',
|
||||
}}>
|
||||
Searching...
|
||||
</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"
|
||||
/>
|
||||
{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' }} />
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
320
frontend/app/services/youtube.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
// Client-side YouTube API Service
|
||||
// Uses YouTube Data API v3 for metadata and search
|
||||
|
||||
const YOUTUBE_API_KEY = process.env.NEXT_PUBLIC_YOUTUBE_API_KEY || '';
|
||||
const YOUTUBE_API_BASE = 'https://www.googleapis.com/youtube/v3';
|
||||
|
||||
export interface YouTubeVideo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
channelTitle: string;
|
||||
channelId: string;
|
||||
publishedAt: string;
|
||||
viewCount: string;
|
||||
likeCount: string;
|
||||
commentCount: string;
|
||||
duration: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface YouTubeSearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
channelTitle: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export interface YouTubeChannel {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
subscriberCount: string;
|
||||
videoCount: string;
|
||||
customUrl?: string;
|
||||
}
|
||||
|
||||
export interface YouTubeComment {
|
||||
id: string;
|
||||
text: string;
|
||||
author: string;
|
||||
authorProfileImage: string;
|
||||
publishedAt: string;
|
||||
likeCount: number;
|
||||
isReply: boolean;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
// Helper to format ISO 8601 duration to human readable
|
||||
function formatDuration(isoDuration: string): string {
|
||||
const match = isoDuration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||
if (!match) return isoDuration;
|
||||
|
||||
const hours = parseInt(match[1] || '0', 10);
|
||||
const minutes = parseInt(match[2] || '0', 10);
|
||||
const seconds = parseInt(match[3] || '0', 10);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format numbers with K, M suffixes
|
||||
function formatNumber(num: string | number): string {
|
||||
const n = typeof num === 'string' ? parseInt(num, 10) : num;
|
||||
if (isNaN(n)) return '0';
|
||||
|
||||
if (n >= 1000000) {
|
||||
return (n / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (n >= 1000) {
|
||||
return (n / 1000).toFixed(0) + 'K';
|
||||
}
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
export class YouTubeAPI {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
this.apiKey = apiKey || YOUTUBE_API_KEY;
|
||||
if (!this.apiKey) {
|
||||
console.warn('YouTube API key not set. Set NEXT_PUBLIC_YOUTUBE_API_KEY in .env.local');
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch(endpoint: string, params: Record<string, string> = {}): Promise<any> {
|
||||
const url = new URL(`${YOUTUBE_API_BASE}${endpoint}`);
|
||||
url.searchParams.set('key', this.apiKey);
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
// Handle specific quota exceeded error
|
||||
if (response.status === 403 && errorData?.error?.reason === 'quotaExceeded') {
|
||||
throw new Error('YouTube API quota exceeded. Please try again later or request a quota increase.');
|
||||
}
|
||||
|
||||
// Handle API key expired error
|
||||
if (response.status === 400 && errorData?.error?.reason === 'API_KEY_INVALID') {
|
||||
throw new Error('YouTube API key is invalid or expired. Please check your API key.');
|
||||
}
|
||||
|
||||
throw new Error(`YouTube API error: ${response.status} ${response.statusText} ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Search for videos
|
||||
async searchVideos(query: string, maxResults: number = 20): Promise<YouTubeSearchResult[]> {
|
||||
const data = await this.fetch('/search', {
|
||||
part: 'snippet',
|
||||
q: query,
|
||||
type: 'video',
|
||||
maxResults: maxResults.toString(),
|
||||
order: 'relevance',
|
||||
});
|
||||
|
||||
return data.items?.map((item: any) => ({
|
||||
id: item.id.videoId,
|
||||
title: item.snippet.title,
|
||||
thumbnail: `https://i.ytimg.com/vi/${item.id.videoId}/mqdefault.jpg`,
|
||||
channelTitle: item.snippet.channelTitle,
|
||||
channelId: item.snippet.channelId,
|
||||
})) || [];
|
||||
}
|
||||
|
||||
// Get video details
|
||||
async getVideoDetails(videoId: string): Promise<YouTubeVideo | null> {
|
||||
const data = await this.fetch('/videos', {
|
||||
part: 'snippet,statistics,contentDetails',
|
||||
id: videoId,
|
||||
});
|
||||
|
||||
const video = data.items?.[0];
|
||||
if (!video) return null;
|
||||
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.snippet.title,
|
||||
description: video.snippet.description,
|
||||
thumbnail: `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`,
|
||||
channelTitle: video.snippet.channelTitle,
|
||||
channelId: video.snippet.channelId,
|
||||
publishedAt: video.snippet.publishedAt,
|
||||
viewCount: formatNumber(video.statistics?.viewCount || '0'),
|
||||
likeCount: formatNumber(video.statistics?.likeCount || '0'),
|
||||
commentCount: formatNumber(video.statistics?.commentCount || '0'),
|
||||
duration: formatDuration(video.contentDetails?.duration || ''),
|
||||
tags: video.snippet.tags,
|
||||
};
|
||||
}
|
||||
|
||||
// Get multiple video details
|
||||
async getVideosDetails(videoIds: string[]): Promise<YouTubeVideo[]> {
|
||||
if (videoIds.length === 0) return [];
|
||||
|
||||
// API allows max 50 IDs per request
|
||||
const batchSize = 50;
|
||||
const results: YouTubeVideo[] = [];
|
||||
|
||||
for (let i = 0; i < videoIds.length; i += batchSize) {
|
||||
const batch = videoIds.slice(i, i + batchSize).join(',');
|
||||
const data = await this.fetch('/videos', {
|
||||
part: 'snippet,statistics,contentDetails',
|
||||
id: batch,
|
||||
});
|
||||
|
||||
const videos = data.items?.map((video: any) => ({
|
||||
id: video.id,
|
||||
title: video.snippet.title,
|
||||
description: video.snippet.description,
|
||||
thumbnail: `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`,
|
||||
channelTitle: video.snippet.channelTitle,
|
||||
channelId: video.snippet.channelId,
|
||||
publishedAt: video.snippet.publishedAt,
|
||||
viewCount: formatNumber(video.statistics?.viewCount || '0'),
|
||||
likeCount: formatNumber(video.statistics?.likeCount || '0'),
|
||||
commentCount: formatNumber(video.statistics?.commentCount || '0'),
|
||||
duration: formatDuration(video.contentDetails?.duration || ''),
|
||||
tags: video.snippet.tags,
|
||||
})) || [];
|
||||
|
||||
results.push(...videos);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Get channel details
|
||||
async getChannelDetails(channelId: string): Promise<YouTubeChannel | null> {
|
||||
const data = await this.fetch('/channels', {
|
||||
part: 'snippet,statistics',
|
||||
id: channelId,
|
||||
});
|
||||
|
||||
const channel = data.items?.[0];
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
id: channel.id,
|
||||
title: channel.snippet.title,
|
||||
description: channel.snippet.description,
|
||||
thumbnail: channel.snippet.thumbnails?.high?.url || channel.snippet.thumbnails?.default?.url,
|
||||
subscriberCount: formatNumber(channel.statistics?.subscriberCount || '0'),
|
||||
videoCount: formatNumber(channel.statistics?.videoCount || '0'),
|
||||
customUrl: channel.snippet.customUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Get channel videos
|
||||
async getChannelVideos(channelId: string, maxResults: number = 30): Promise<YouTubeSearchResult[]> {
|
||||
// First get uploads playlist ID
|
||||
const channelData = await this.fetch('/channels', {
|
||||
part: 'contentDetails',
|
||||
id: channelId,
|
||||
});
|
||||
|
||||
const uploadsPlaylistId = channelData.items?.[0]?.contentDetails?.relatedPlaylists?.uploads;
|
||||
if (!uploadsPlaylistId) return [];
|
||||
|
||||
// Then get videos from that playlist
|
||||
const playlistData = await this.fetch('/playlistItems', {
|
||||
part: 'snippet',
|
||||
playlistId: uploadsPlaylistId,
|
||||
maxResults: maxResults.toString(),
|
||||
});
|
||||
|
||||
return playlistData.items?.map((item: any) => ({
|
||||
id: item.snippet.resourceId.videoId,
|
||||
title: item.snippet.title,
|
||||
thumbnail: item.snippet.thumbnails?.high?.url || item.snippet.thumbnails?.default?.url,
|
||||
channelTitle: item.snippet.channelTitle,
|
||||
channelId: item.snippet.channelId,
|
||||
})) || [];
|
||||
}
|
||||
|
||||
// Get comments for a video
|
||||
async getComments(videoId: string, maxResults: number = 20): Promise<YouTubeComment[]> {
|
||||
try {
|
||||
const data = await this.fetch('/commentThreads', {
|
||||
part: 'snippet,replies',
|
||||
videoId: videoId,
|
||||
maxResults: maxResults.toString(),
|
||||
order: 'relevance',
|
||||
textFormat: 'plainText',
|
||||
});
|
||||
|
||||
return data.items?.map((item: any) => ({
|
||||
id: item.id,
|
||||
text: item.snippet.topLevelComment.snippet.textDisplay,
|
||||
author: item.snippet.topLevelComment.snippet.authorDisplayName,
|
||||
authorProfileImage: item.snippet.topLevelComment.snippet.authorProfileImageUrl,
|
||||
publishedAt: item.snippet.topLevelComment.snippet.publishedAt,
|
||||
likeCount: item.snippet.topLevelComment.snippet.likeCount || 0,
|
||||
isReply: false,
|
||||
})) || [];
|
||||
} catch (error) {
|
||||
// Comments might be disabled
|
||||
console.warn('Failed to fetch comments:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get trending videos
|
||||
async getTrendingVideos(regionCode: string = 'US', maxResults: number = 20): Promise<YouTubeVideo[]> {
|
||||
const data = await this.fetch('/videos', {
|
||||
part: 'snippet,statistics,contentDetails',
|
||||
chart: 'mostPopular',
|
||||
regionCode: regionCode,
|
||||
maxResults: maxResults.toString(),
|
||||
});
|
||||
|
||||
return data.items?.map((video: any) => ({
|
||||
id: video.id,
|
||||
title: video.snippet.title,
|
||||
description: video.snippet.description,
|
||||
thumbnail: `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`,
|
||||
channelTitle: video.snippet.channelTitle,
|
||||
channelId: video.snippet.channelId,
|
||||
publishedAt: video.snippet.publishedAt,
|
||||
viewCount: formatNumber(video.statistics?.viewCount || '0'),
|
||||
likeCount: formatNumber(video.statistics?.likeCount || '0'),
|
||||
commentCount: formatNumber(video.statistics?.commentCount || '0'),
|
||||
duration: formatDuration(video.contentDetails?.duration || ''),
|
||||
tags: video.snippet.tags,
|
||||
})) || [];
|
||||
}
|
||||
|
||||
// Get related videos (using search with related query)
|
||||
async getRelatedVideos(videoId: string, maxResults: number = 10): Promise<YouTubeSearchResult[]> {
|
||||
// First get video details to get title for related search
|
||||
const videoDetails = await this.getVideoDetails(videoId);
|
||||
if (!videoDetails) return [];
|
||||
|
||||
// Use related query based on video title and channel
|
||||
const query = `${videoDetails.channelTitle} ${videoDetails.title.split(' ').slice(0, 5).join(' ')}`;
|
||||
|
||||
return this.searchVideos(query, maxResults);
|
||||
}
|
||||
|
||||
// Get suggestions for search
|
||||
async getSuggestions(query: string): Promise<string[]> {
|
||||
// YouTube doesn't have a suggestions API, so we'll return empty array
|
||||
// Could implement with autocomplete API if available
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const youtubeAPI = new YouTubeAPI();
|
||||
18
frontend/app/shorts/page.tsx
Executable file → Normal file
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { IoHeart, IoHeartOutline, IoChatbubbleOutline, IoShareOutline, IoEllipsisHorizontal, IoMusicalNote, IoRefresh, IoPlay, IoVolumeMute, IoVolumeHigh } from 'react-icons/io5';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -225,7 +226,7 @@ function ShortCard({ video, isActive }: { video: ShortVideo; isActive: boolean }
|
|||
/>
|
||||
{loading && (
|
||||
<div style={loadingOverlayStyle}>
|
||||
<div style={spinnerStyle}></div>
|
||||
<LoadingSpinner color="white" />
|
||||
</div>
|
||||
)}
|
||||
{error && !useFallback && (
|
||||
|
|
@ -413,15 +414,6 @@ const openBtnStyle: React.CSSProperties = {
|
|||
zIndex: 10,
|
||||
};
|
||||
|
||||
const spinnerStyle: React.CSSProperties = {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #333',
|
||||
borderTopColor: '#ff0050',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
};
|
||||
|
||||
export default function ShortsPage() {
|
||||
const [shorts, setShorts] = useState<ShortVideo[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
|
@ -473,7 +465,7 @@ export default function ShortsPage() {
|
|||
if (loading) return (
|
||||
<div style={pageStyle}>
|
||||
<div style={{ ...spinnerContainerStyle, width: '300px', height: '500px' }}>
|
||||
<div style={spinnerStyle}></div>
|
||||
<LoadingSpinner color="white" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -492,11 +484,10 @@ 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' }}>
|
||||
<div style={spinnerStyle}></div>
|
||||
<LoadingSpinner color="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -506,5 +497,4 @@ export default function ShortsPage() {
|
|||
const pageStyle: React.CSSProperties = { height: 'calc(100vh - 56px)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f0f0f' };
|
||||
const 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; }';
|
||||
|
|
|
|||
178
frontend/app/storage.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
'use client';
|
||||
|
||||
// Local storage keys
|
||||
const HISTORY_KEY = 'kvtube_history';
|
||||
const SUBSCRIPTIONS_KEY = 'kvtube_subscriptions';
|
||||
const SAVED_VIDEOS_KEY = 'kvtube_saved_videos';
|
||||
|
||||
export interface HistoryItem {
|
||||
videoId: string;
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
channelTitle: string;
|
||||
watchedAt: number;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
channelAvatar: string;
|
||||
subscribedAt: number;
|
||||
}
|
||||
|
||||
export interface SavedVideo {
|
||||
videoId: string;
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
channelTitle: string;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
// Get items from localStorage
|
||||
function getFromStorage<T>(key: string): T[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save items to localStorage
|
||||
function saveToStorage<T>(key: string, items: T[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(items));
|
||||
} catch (e) {
|
||||
console.error('Storage error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== HISTORY ====================
|
||||
|
||||
export function getHistory(limit: number = 50): HistoryItem[] {
|
||||
const history = getFromStorage<HistoryItem>(HISTORY_KEY);
|
||||
// Sort by most recent first
|
||||
return history.sort((a, b) => b.watchedAt - a.watchedAt).slice(0, limit);
|
||||
}
|
||||
|
||||
export function addToHistory(video: { videoId: string; title: string; thumbnail: string; channelTitle?: string }): void {
|
||||
const history = getFromStorage<HistoryItem>(HISTORY_KEY);
|
||||
|
||||
// Remove duplicate if exists
|
||||
const filtered = history.filter(h => h.videoId !== video.videoId);
|
||||
|
||||
// Add new entry at the beginning
|
||||
const newItem: HistoryItem = {
|
||||
videoId: video.videoId,
|
||||
title: video.title,
|
||||
thumbnail: video.thumbnail,
|
||||
channelTitle: video.channelTitle || 'Unknown',
|
||||
watchedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Keep only last 100 items
|
||||
const updated = [newItem, ...filtered].slice(0, 100);
|
||||
saveToStorage(HISTORY_KEY, updated);
|
||||
}
|
||||
|
||||
export function removeFromHistory(videoId: string): void {
|
||||
const history = getFromStorage<HistoryItem>(HISTORY_KEY);
|
||||
const filtered = history.filter(h => h.videoId !== videoId);
|
||||
saveToStorage(HISTORY_KEY, filtered);
|
||||
}
|
||||
|
||||
export function clearHistory(): void {
|
||||
saveToStorage(HISTORY_KEY, []);
|
||||
}
|
||||
|
||||
// ==================== SUBSCRIPTIONS ====================
|
||||
|
||||
export function getSubscriptions(): Subscription[] {
|
||||
return getFromStorage<Subscription>(SUBSCRIPTIONS_KEY)
|
||||
.sort((a, b) => b.subscribedAt - a.subscribedAt);
|
||||
}
|
||||
|
||||
export function subscribe(channel: { channelId: string; channelName: string; channelAvatar?: string }): void {
|
||||
const subs = getFromStorage<Subscription>(SUBSCRIPTIONS_KEY);
|
||||
|
||||
// Check if already subscribed
|
||||
if (subs.some(s => s.channelId === channel.channelId)) return;
|
||||
|
||||
const newSub: Subscription = {
|
||||
channelId: channel.channelId,
|
||||
channelName: channel.channelName,
|
||||
channelAvatar: channel.channelAvatar || '',
|
||||
subscribedAt: Date.now(),
|
||||
};
|
||||
|
||||
saveToStorage(SUBSCRIPTIONS_KEY, [...subs, newSub]);
|
||||
}
|
||||
|
||||
export function unsubscribe(channelId: string): void {
|
||||
const subs = getFromStorage<Subscription>(SUBSCRIPTIONS_KEY);
|
||||
const filtered = subs.filter(s => s.channelId !== channelId);
|
||||
saveToStorage(SUBSCRIPTIONS_KEY, filtered);
|
||||
}
|
||||
|
||||
export function isSubscribed(channelId: string): boolean {
|
||||
const subs = getFromStorage<Subscription>(SUBSCRIPTIONS_KEY);
|
||||
return subs.some(s => s.channelId === channelId);
|
||||
}
|
||||
|
||||
export function toggleSubscription(channel: { channelId: string; channelName: string; channelAvatar?: string }): boolean {
|
||||
if (isSubscribed(channel.channelId)) {
|
||||
unsubscribe(channel.channelId);
|
||||
return false;
|
||||
} else {
|
||||
subscribe(channel);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SAVED VIDEOS ====================
|
||||
|
||||
export function getSavedVideos(limit: number = 50): SavedVideo[] {
|
||||
const saved = getFromStorage<SavedVideo>(SAVED_VIDEOS_KEY);
|
||||
return saved.sort((a, b) => b.savedAt - a.savedAt).slice(0, limit);
|
||||
}
|
||||
|
||||
export function saveVideo(video: { videoId: string; title: string; thumbnail: string; channelTitle?: string }): void {
|
||||
const saved = getFromStorage<SavedVideo>(SAVED_VIDEOS_KEY);
|
||||
|
||||
// Remove duplicate if exists
|
||||
const filtered = saved.filter(v => v.videoId !== video.videoId);
|
||||
|
||||
const newVideo: SavedVideo = {
|
||||
videoId: video.videoId,
|
||||
title: video.title,
|
||||
thumbnail: video.thumbnail,
|
||||
channelTitle: video.channelTitle || 'Unknown',
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
|
||||
const updated = [newVideo, ...filtered];
|
||||
saveToStorage(SAVED_VIDEOS_KEY, updated);
|
||||
}
|
||||
|
||||
export function unsaveVideo(videoId: string): void {
|
||||
const saved = getFromStorage<SavedVideo>(SAVED_VIDEOS_KEY);
|
||||
const filtered = saved.filter(v => v.videoId !== videoId);
|
||||
saveToStorage(SAVED_VIDEOS_KEY, filtered);
|
||||
}
|
||||
|
||||
export function isVideoSaved(videoId: string): boolean {
|
||||
const saved = getFromStorage<SavedVideo>(SAVED_VIDEOS_KEY);
|
||||
return saved.some(v => v.videoId === videoId);
|
||||
}
|
||||
|
||||
export function toggleSaveVideo(video: { videoId: string; title: string; thumbnail: string; channelTitle?: string }): boolean {
|
||||
if (isVideoSaved(video.videoId)) {
|
||||
unsaveVideo(video.videoId);
|
||||
return false;
|
||||
} else {
|
||||
saveVideo(video);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
0
frontend/app/utils.ts
Executable file → Normal file
996
frontend/app/watch/ClientWatchPage.tsx
Normal file
|
|
@ -0,0 +1,996 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import YouTubePlayer from './YouTubePlayer';
|
||||
import { getVideoDetailsClient, getRelatedVideosClient, getCommentsClient, searchVideosClient } from '../clientActions';
|
||||
import { VideoData } from '../constants';
|
||||
import { isSubscribed, toggleSubscription, addToHistory, isVideoSaved, toggleSaveVideo } from '../storage';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Simple cache for API responses to reduce quota usage
|
||||
const apiCache = new Map<string, { data: any; timestamp: number }>();
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function getCachedData(key: string) {
|
||||
const cached = apiCache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCachedData(key: string, data: any) {
|
||||
apiCache.set(key, { data, timestamp: Date.now() });
|
||||
// Clean up old cache entries
|
||||
if (apiCache.size > 100) {
|
||||
const oldestKey = apiCache.keys().next().value;
|
||||
if (oldestKey) {
|
||||
apiCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video Info Section
|
||||
function VideoInfo({ video }: { video: any }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [subscribing, setSubscribing] = useState(false);
|
||||
|
||||
// Check subscription and save status on mount
|
||||
useEffect(() => {
|
||||
if (video?.channelId) {
|
||||
setSubscribed(isSubscribed(video.channelId));
|
||||
}
|
||||
if (video?.id) {
|
||||
setIsSaved(isVideoSaved(video.id));
|
||||
}
|
||||
}, [video?.channelId, video?.id]);
|
||||
|
||||
const handleSubscribe = useCallback(() => {
|
||||
if (!video?.channelId || subscribing) return;
|
||||
|
||||
setSubscribing(true);
|
||||
try {
|
||||
const nowSubscribed = toggleSubscription({
|
||||
channelId: video.channelId,
|
||||
channelName: video.channelTitle,
|
||||
channelAvatar: '',
|
||||
});
|
||||
setSubscribed(nowSubscribed);
|
||||
} catch (error) {
|
||||
console.error('Subscribe error:', error);
|
||||
} finally {
|
||||
setSubscribing(false);
|
||||
}
|
||||
}, [video?.channelId, video?.channelTitle, subscribing]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!video?.id) return;
|
||||
|
||||
try {
|
||||
const nowSaved = toggleSaveVideo({
|
||||
videoId: video.id,
|
||||
title: video.title,
|
||||
thumbnail: video.thumbnail,
|
||||
channelTitle: video.channelTitle,
|
||||
});
|
||||
setIsSaved(nowSaved);
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
}
|
||||
}, [video?.id, video?.title, video?.thumbnail, video?.channelTitle]);
|
||||
|
||||
if (!video) return null;
|
||||
|
||||
const description = video.description || '';
|
||||
const hasDescription = description.length > 0;
|
||||
const shouldTruncate = description.length > 300;
|
||||
const displayDescription = expanded ? description : description.slice(0, 300) + (shouldTruncate ? '...' : '');
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr || dateStr === 'Invalid Date') return '';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Format view count
|
||||
const formatViews = (views: string) => {
|
||||
if (!views || views === '0') return 'No views';
|
||||
const num = parseInt(views.replace(/[^0-9]/g, '') || '0');
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M views';
|
||||
if (num >= 1000) return (num / 1000).toFixed(0) + 'K views';
|
||||
return num.toLocaleString() + ' views';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
{/* Title */}
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
color: 'var(--yt-text-primary)',
|
||||
lineHeight: '1.3',
|
||||
}}>
|
||||
{video.title || 'Untitled Video'}
|
||||
</h1>
|
||||
|
||||
{/* Channel Info & Actions Row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid var(--yt-border)',
|
||||
}}>
|
||||
{/* Channel - only show name, no avatar */}
|
||||
<div style={{
|
||||
color: 'var(--yt-text-primary)',
|
||||
fontWeight: '500',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
{video.channelTitle || 'Unknown Channel'}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Subscribe, Share, Save */}
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Subscribe Button with Toggle State */}
|
||||
<button
|
||||
onClick={handleSubscribe}
|
||||
disabled={subscribing}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: subscribed ? 'var(--yt-hover)' : '#cc0000',
|
||||
color: subscribed ? 'var(--yt-text-primary)' : '#fff',
|
||||
border: subscribed ? '1px solid var(--yt-border)' : 'none',
|
||||
borderRadius: '18px',
|
||||
cursor: subscribing ? 'wait' : 'pointer',
|
||||
fontWeight: '500',
|
||||
fontSize: '13px',
|
||||
transition: 'all 0.2s',
|
||||
opacity: subscribing ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{subscribing ? (
|
||||
'...'
|
||||
) : subscribed ? (
|
||||
<>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
Subscribed
|
||||
</>
|
||||
) : (
|
||||
'Subscribe'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Share Button */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: video.title || 'Check out this video',
|
||||
url: window.location.href,
|
||||
});
|
||||
return;
|
||||
} catch (shareErr: any) {
|
||||
if (shareErr.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
alert('Link copied to clipboard!');
|
||||
} catch (err) {
|
||||
alert('Could not share or copy link');
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'var(--yt-hover)',
|
||||
color: 'var(--yt-text-primary)',
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9.41 15.95L12 13.36l2.59 2.59L16 14.54l-2.59-2.59L16 9.36l-1.41-1.41L12 10.54 9.41 7.95 8 9.36l2.59 2.59L8 14.54z"/>
|
||||
</svg>
|
||||
Share
|
||||
</button>
|
||||
|
||||
{/* Save Button with Toggle State */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: isSaved ? 'var(--yt-blue)' : 'var(--yt-hover)',
|
||||
color: isSaved ? '#fff' : 'var(--yt-text-primary)',
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{isSaved ? (
|
||||
<>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
|
||||
</svg>
|
||||
Saved
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 10H2v2h12v-2zm0-4H2v2h12V6zm4 8v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2 16h8v-2H2v2z"/>
|
||||
</svg>
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Box */}
|
||||
<div style={{
|
||||
backgroundColor: 'var(--yt-hover)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px',
|
||||
marginTop: '12px',
|
||||
}}>
|
||||
{/* Views and Date */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginBottom: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--yt-text-primary)'
|
||||
}}>
|
||||
<span>{formatViews(video.viewCount)}</span>
|
||||
{video.publishedAt && formatDate(video.publishedAt) && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDate(video.publishedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{hasDescription ? (
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--yt-text-primary)',
|
||||
lineHeight: '1.5',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{displayDescription}
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--yt-blue)',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '500',
|
||||
padding: 0,
|
||||
marginLeft: '4px',
|
||||
}}
|
||||
>
|
||||
{expanded ? ' Show less' : ' ...more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tags */}
|
||||
{video.tags && video.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '12px' }}>
|
||||
{video.tags.slice(0, 10).map((tag: string, i: number) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: 'var(--yt-background)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '14px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--yt-blue)',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mix Playlist Component
|
||||
function MixPlaylist({ videos, currentIndex, onVideoSelect, title }: {
|
||||
videos: VideoData[];
|
||||
currentIndex: number;
|
||||
onVideoSelect: (index: number) => void;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'var(--yt-hover)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--yt-border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: '600', margin: 0, color: 'var(--yt-text-primary)' }}>
|
||||
{title || 'Mix Playlist'}
|
||||
</h3>
|
||||
<p style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', margin: '2px 0 0 0' }}>
|
||||
{videos.length} videos • Auto-play is on
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video List */}
|
||||
<div style={{ maxHeight: '360px', overflowY: 'auto' }}>
|
||||
{videos.map((video, index) => (
|
||||
<div
|
||||
key={video.id}
|
||||
onClick={() => onVideoSelect(index)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: index === currentIndex ? 'var(--yt-active)' : 'transparent',
|
||||
borderLeft: index === currentIndex ? '3px solid var(--yt-blue)' : '3px solid transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (index !== currentIndex) {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.05)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (index !== currentIndex) {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail with index */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<img
|
||||
src={video.thumbnail || `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`}
|
||||
alt={video.title}
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '56px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/default.jpg`;
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '3px',
|
||||
left: '3px',
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
color: '#fff',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px',
|
||||
}}>
|
||||
{index + 1}/{videos.length}
|
||||
</div>
|
||||
{index === currentIndex && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
borderRadius: '50%',
|
||||
padding: '6px',
|
||||
}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: index === currentIndex ? '600' : '500',
|
||||
color: 'var(--yt-text-primary)',
|
||||
lineHeight: '1.2',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{video.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||
{video.uploader}
|
||||
</div>
|
||||
{video.duration && (
|
||||
<div style={{ fontSize: '10px', color: 'var(--yt-text-secondary)', marginTop: '1px' }}>
|
||||
{video.duration}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Comment Section
|
||||
function CommentSection({ videoId }: { videoId: string }) {
|
||||
const [comments, setComments] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadComments = async () => {
|
||||
try {
|
||||
const data = await getCommentsClient(videoId, 50);
|
||||
setComments(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load comments:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadComments();
|
||||
}, [videoId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '24px 0', color: 'var(--yt-text-secondary)' }}>
|
||||
Loading comments...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayedComments = showAll ? comments : comments.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 0', borderTop: '1px solid var(--yt-border)' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: '600', marginBottom: '16px', color: 'var(--yt-text-primary)' }}>
|
||||
{comments.length} Comments
|
||||
</h2>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '24px' }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--yt-text-secondary)">
|
||||
<path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/>
|
||||
</svg>
|
||||
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)' }}>Sort by</span>
|
||||
</div>
|
||||
|
||||
{/* Comments List */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
{displayedComments.map((comment) => (
|
||||
<div key={comment.id} style={{ display: 'flex', gap: '12px' }}>
|
||||
{comment.author_thumbnail ? (
|
||||
<img
|
||||
src={comment.author_thumbnail}
|
||||
alt={comment.author}
|
||||
style={{ width: '40px', height: '40px', borderRadius: '50%', backgroundColor: 'var(--yt-hover)', flexShrink: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: '500', color: 'var(--yt-text-primary)' }}>
|
||||
{comment.author}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--yt-text-secondary)' }}>
|
||||
{comment.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--yt-text-primary)', marginTop: '4px', lineHeight: '1.5' }}>
|
||||
{comment.text}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '8px' }}>
|
||||
<button style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/>
|
||||
</svg>
|
||||
{comment.likes}
|
||||
</button>
|
||||
<button style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--yt-blue)',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{comments.length > 5 && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--yt-blue)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
padding: '8px 0',
|
||||
}}
|
||||
>
|
||||
{showAll ? 'Show less' : `Show all ${comments.length} comments`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientWatchPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const videoId = searchParams.get('v');
|
||||
const [videoInfo, setVideoInfo] = useState<any>(null);
|
||||
const [relatedVideos, setRelatedVideos] = useState<VideoData[]>([]);
|
||||
const [mixPlaylist, setMixPlaylist] = useState<VideoData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||
const [activeTab, setActiveTab] = useState<'upnext' | 'mix'>('upnext');
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
// Scroll to top when video changes or page loads
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}, [videoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoId) return;
|
||||
|
||||
const loadVideoData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
|
||||
// Check cache for video details
|
||||
let video = getCachedData(`video_${videoId}`);
|
||||
if (!video) {
|
||||
video = await getVideoDetailsClient(videoId);
|
||||
if (video) setCachedData(`video_${videoId}`, video);
|
||||
}
|
||||
setVideoInfo(video);
|
||||
|
||||
// Add to watch history (localStorage)
|
||||
if (video) {
|
||||
addToHistory({
|
||||
videoId: videoId,
|
||||
title: video.title,
|
||||
thumbnail: video.thumbnail,
|
||||
channelTitle: video.channelTitle,
|
||||
});
|
||||
}
|
||||
|
||||
// Get related videos - use channel name and video title for better results
|
||||
// Even if video is null, we can still try to get related videos
|
||||
const searchTerms = video?.title?.split(' ').filter((w: string) => w.length > 3).slice(0, 5).join(' ') || 'music';
|
||||
const channelName = video?.channelTitle || '';
|
||||
|
||||
// Check cache for related videos
|
||||
const cacheKey = `related_${videoId}_${searchTerms}`;
|
||||
let relatedResults = getCachedData(cacheKey);
|
||||
let mixResults = getCachedData(`mix_${videoId}_${searchTerms}`);
|
||||
|
||||
if (!relatedResults || !mixResults) {
|
||||
// Optimized: Use just 2 search requests instead of 5 to save API quota
|
||||
[relatedResults, mixResults] = await Promise.all([
|
||||
searchVideosClient(`${channelName} ${searchTerms}`, 20),
|
||||
searchVideosClient(`${searchTerms} mix compilation`, 20),
|
||||
]);
|
||||
|
||||
if (relatedResults && relatedResults.length > 0) setCachedData(cacheKey, relatedResults);
|
||||
if (mixResults && mixResults.length > 0) setCachedData(`mix_${videoId}_${searchTerms}`, mixResults);
|
||||
}
|
||||
|
||||
// Deduplicate and filter related videos - ensure arrays
|
||||
const uniqueRelated = Array.isArray(relatedResults) ? relatedResults.filter((v, index, self) =>
|
||||
index === self.findIndex(item => item.id === v.id) && v.id !== videoId
|
||||
) : [];
|
||||
|
||||
setCurrentIndex(0);
|
||||
setRelatedVideos(uniqueRelated);
|
||||
|
||||
// Use remaining videos for mix playlist - ensure array
|
||||
const uniqueMix = Array.isArray(mixResults) ? mixResults.filter((v, index, self) =>
|
||||
index === self.findIndex(item => item.id === v.id) &&
|
||||
v.id !== videoId &&
|
||||
!uniqueRelated.some(r => r.id === v.id)
|
||||
) : [];
|
||||
|
||||
setMixPlaylist(uniqueMix.slice(0, 20));
|
||||
|
||||
// Set error message if video details failed but we have related videos
|
||||
if (!video) {
|
||||
setApiError('Video info unavailable, but you can still browse related videos.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load video data:', error);
|
||||
// Fallback with fewer requests
|
||||
try {
|
||||
const fallbackResults = await searchVideosClient('music popular', 20);
|
||||
setRelatedVideos(Array.isArray(fallbackResults) ? fallbackResults.slice(0, 10) : []);
|
||||
setMixPlaylist(Array.isArray(fallbackResults) ? fallbackResults.slice(10, 20) : []);
|
||||
setApiError('Unable to load video details. Showing suggested videos instead.');
|
||||
} catch (e: any) {
|
||||
console.error('Fallback also failed:', e);
|
||||
// Set empty arrays to show user-friendly message
|
||||
setRelatedVideos([]);
|
||||
setMixPlaylist([]);
|
||||
|
||||
// Set user-friendly error message
|
||||
if (e?.message?.includes('quota exceeded')) {
|
||||
setApiError('YouTube API quota exceeded. Please try again later.');
|
||||
} else if (e?.message?.includes('API key')) {
|
||||
setApiError('API key issue. Please check configuration.');
|
||||
} else {
|
||||
setApiError('Unable to load related videos. Please try again.');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVideoData();
|
||||
}, [videoId]);
|
||||
|
||||
const handleVideoSelect = (index: number) => {
|
||||
const video = activeTab === 'upnext' ? relatedVideos[index] : mixPlaylist[index];
|
||||
if (video) {
|
||||
router.push(`/watch?v=${video.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentIndex > 0) {
|
||||
const prevVideo = relatedVideos[currentIndex - 1];
|
||||
router.push(`/watch?v=${prevVideo.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const playlist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
|
||||
if (currentIndex < playlist.length - 1) {
|
||||
const nextVideo = playlist[currentIndex + 1];
|
||||
router.push(`/watch?v=${nextVideo.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoEnd = () => {
|
||||
const playlist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
|
||||
if (currentIndex < playlist.length - 1) {
|
||||
handleNext();
|
||||
}
|
||||
};
|
||||
|
||||
if (!videoId) {
|
||||
return <div style={{ padding: '2rem', color: 'var(--yt-text-primary)' }}>No video ID provided</div>;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner fullScreen size="large" text="Loading video..." />;
|
||||
}
|
||||
|
||||
const currentPlaylist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'var(--yt-background)',
|
||||
color: 'var(--yt-text-primary)',
|
||||
minHeight: '100vh',
|
||||
}}>
|
||||
<div className="watch-page-container" style={{
|
||||
maxWidth: '1800px',
|
||||
margin: '0 auto',
|
||||
padding: '24px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 400px',
|
||||
gap: '24px',
|
||||
}}>
|
||||
{/* Main Content */}
|
||||
<div className="watch-main">
|
||||
{/* Video Player */}
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<YouTubePlayer
|
||||
videoId={videoId}
|
||||
title={videoInfo?.title}
|
||||
autoplay={true}
|
||||
onVideoEnd={handleVideoEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Player Controls */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentIndex <= 0}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: currentIndex > 0 ? 'var(--yt-hover)' : 'transparent',
|
||||
color: currentIndex > 0 ? 'var(--yt-text-primary)' : 'var(--yt-text-secondary)',
|
||||
border: '1px solid var(--yt-border)',
|
||||
borderRadius: '18px',
|
||||
cursor: currentIndex > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
opacity: currentIndex > 0 ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex >= currentPlaylist.length - 1}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: currentIndex < currentPlaylist.length - 1 ? 'var(--yt-blue)' : 'var(--yt-hover)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
cursor: currentIndex < currentPlaylist.length - 1 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Next
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video Info */}
|
||||
<VideoInfo video={videoInfo} />
|
||||
|
||||
{/* Comments */}
|
||||
<CommentSection videoId={videoId} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="watch-sidebar" style={{
|
||||
position: 'sticky',
|
||||
top: '70px',
|
||||
height: 'fit-content',
|
||||
maxHeight: 'calc(100vh - 80px)',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{/* Mix Playlist - Always on top */}
|
||||
<MixPlaylist
|
||||
videos={mixPlaylist}
|
||||
currentIndex={currentIndex}
|
||||
onVideoSelect={handleVideoSelect}
|
||||
title={videoInfo?.title ? `Mix - ${videoInfo.title.split(' ').slice(0, 3).join(' ')}` : 'Mix Playlist'}
|
||||
/>
|
||||
|
||||
{/* API Error Message */}
|
||||
{apiError && (
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
backgroundColor: 'rgba(255, 0, 0, 0.1)',
|
||||
border: '1px solid rgba(255, 0, 0, 0.2)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--yt-text-secondary)',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Up Next Section */}
|
||||
<div style={{
|
||||
backgroundColor: 'var(--yt-hover)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--yt-border)',
|
||||
}}>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: '600', margin: 0, color: 'var(--yt-text-primary)' }}>
|
||||
Up Next
|
||||
</h3>
|
||||
<span style={{ fontSize: '11px', color: 'var(--yt-text-secondary)' }}>
|
||||
{relatedVideos.length} videos
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{relatedVideos.slice(0, 8).map((video, index) => (
|
||||
<div
|
||||
key={video.id}
|
||||
onClick={() => handleVideoSelect(index)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: index === currentIndex ? 'var(--yt-active)' : 'transparent',
|
||||
borderLeft: index === currentIndex ? '3px solid var(--yt-blue)' : '3px solid transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (index !== currentIndex) {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.05)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (index !== currentIndex) {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<img
|
||||
src={video.thumbnail || `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`}
|
||||
alt={video.title}
|
||||
style={{ width: '120px', height: '68px', objectFit: 'cover', borderRadius: '6px' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`;
|
||||
}}
|
||||
/>
|
||||
{video.duration && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '3px',
|
||||
right: '3px',
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
color: '#fff',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px',
|
||||
}}>
|
||||
{video.duration}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--yt-text-primary)',
|
||||
lineHeight: '1.2',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{video.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||
{video.uploader}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Responsive styles */}
|
||||
<style jsx>{`
|
||||
@media (max-width: 1024px) {
|
||||
.watch-page-container {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.watch-sidebar {
|
||||
position: relative !important;
|
||||
top: 0 !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.watch-page-container {
|
||||
padding: 12px !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,393 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Hls: any;
|
||||
}
|
||||
}
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoId: string;
|
||||
title?: string;
|
||||
nextVideoId?: 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={skeletonVideoStyle} className="skeleton" />
|
||||
<div style={skeletonControlsStyle}>
|
||||
<div style={skeletonProgressStyle} className="skeleton" />
|
||||
<div style={skeletonButtonsRowStyle}>
|
||||
<div style={{ ...skeletonButtonStyle, width: '60px' }} className="skeleton" />
|
||||
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
|
||||
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
|
||||
<div style={{ ...skeletonButtonStyle, width: '80px' }} className="skeleton" />
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ ...skeletonButtonStyle, width: '100px' }} className="skeleton" />
|
||||
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
|
||||
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={skeletonCenterStyle}>
|
||||
<div style={skeletonSpinnerStyle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoPlayer({ videoId, title, nextVideoId }: 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 audioUrlRef = useRef<string>('');
|
||||
|
||||
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;
|
||||
|
||||
if (Math.abs(video.currentTime - audio.currentTime) > 0.2) {
|
||||
audio.currentTime = video.currentTime;
|
||||
}
|
||||
|
||||
if (video.paused && !audio.paused) {
|
||||
audio.pause();
|
||||
} else if (!video.paused && audio.paused) {
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (useFallback) return;
|
||||
|
||||
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);
|
||||
setError('Failed to load stream');
|
||||
setUseFallback(true);
|
||||
}
|
||||
};
|
||||
|
||||
const tryLoad = () => {
|
||||
if (window.Hls) {
|
||||
loadStream();
|
||||
} else {
|
||||
setTimeout(tryLoad, 100);
|
||||
}
|
||||
};
|
||||
|
||||
tryLoad();
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (audioHlsRef.current) {
|
||||
audioHlsRef.current.destroy();
|
||||
audioHlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [videoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSeparateAudio) return;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.entries(handlers).forEach(([event, handler]) => {
|
||||
video.removeEventListener(event, handler);
|
||||
});
|
||||
};
|
||||
}, [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);
|
||||
|
||||
video.addEventListener('canplay', handleCanPlay);
|
||||
video.addEventListener('playing', handlePlaying);
|
||||
video.addEventListener('waiting', handleWaiting);
|
||||
video.addEventListener('loadstart', handleLoadStart);
|
||||
|
||||
if (isHLS && window.Hls && window.Hls.isSupported()) {
|
||||
if (hlsRef.current) hlsRef.current.destroy();
|
||||
|
||||
const hls = new window.Hls({
|
||||
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) {
|
||||
setIsLoading(false);
|
||||
setError('Playback error');
|
||||
setUseFallback(true);
|
||||
}
|
||||
});
|
||||
} else if (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({
|
||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
|
||||
},
|
||||
});
|
||||
audioHlsRef.current = audioHls;
|
||||
|
||||
audioHls.loadSource(audioStreamUrl!);
|
||||
audioHls.attachMedia(audio);
|
||||
} else {
|
||||
audio.src = audioStreamUrl!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
video.onended = () => {
|
||||
setIsLoading(false);
|
||||
if (nextVideoId) router.push(`/watch?v=${nextVideoId}`);
|
||||
};
|
||||
};
|
||||
|
||||
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`}
|
||||
style={iframeStyle}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
title={title || 'Video'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
|
||||
{isLoading && <PlayerSkeleton />}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
|
||||
controls
|
||||
playsInline
|
||||
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
||||
/>
|
||||
|
||||
{hasSeparateAudio && <audio ref={audioRef} style={{ display: 'none' }} />}
|
||||
|
||||
{error && (
|
||||
<div style={errorStyle}>
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setUseFallback(true)} style={retryBtnStyle}>Try YouTube Player</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showControls && !error && !isLoading && (
|
||||
<>
|
||||
<a href={`https://www.youtube.com/watch?v=${videoId}`} target="_blank" rel="noopener noreferrer" style={openBtnStyle}>
|
||||
Open on YouTube ↗
|
||||
</a>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isBuffering && !isLoading && (
|
||||
<div style={bufferingOverlayStyle}>
|
||||
<div style={spinnerStyle} />
|
||||
</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 errorStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px', background: 'rgba(0,0,0,0.9)', color: '#ff6b6b' };
|
||||
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', flexDirection: 'column', background: '#000', zIndex: 5 };
|
||||
const skeletonVideoStyle: React.CSSProperties = { flex: 1, margin: '8px', borderRadius: '8px' };
|
||||
const skeletonControlsStyle: React.CSSProperties = { padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: '8px' };
|
||||
const skeletonProgressStyle: React.CSSProperties = { height: '4px', borderRadius: '2px' };
|
||||
const skeletonButtonsRowStyle: React.CSSProperties = { display: 'flex', gap: '8px', alignItems: 'center' };
|
||||
const skeletonButtonStyle: React.CSSProperties = { height: '24px', borderRadius: '4px' };
|
||||
const skeletonCenterStyle: React.CSSProperties = { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
|
||||
const skeletonSpinnerStyle: React.CSSProperties = { width: '48px', height: '48px', border: '4px solid rgba(255,255,255,0.1)', borderTopColor: 'rgba(255,255,255,0.8)', 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' };
|
||||
|
|
@ -1,453 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { PiShareFat } from 'react-icons/pi';
|
||||
import { TfiDownload } from 'react-icons/tfi';
|
||||
|
||||
interface VideoFormat {
|
||||
format_id: string;
|
||||
format_note: string;
|
||||
ext: string;
|
||||
resolution: string;
|
||||
filesize: number;
|
||||
type: string;
|
||||
has_audio?: boolean;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
FFmpeg: any;
|
||||
FFmpegWASM: any;
|
||||
}
|
||||
}
|
||||
|
||||
function getQualityLabel(resolution: string): string {
|
||||
const height = parseInt(resolution) || 0;
|
||||
if (height >= 3840) return '4K UHD';
|
||||
if (height >= 2560) return '2K QHD';
|
||||
if (height >= 1920) return 'Full HD 1080p';
|
||||
if (height >= 1280) return 'HD 720p';
|
||||
if (height >= 854) return 'SD 480p';
|
||||
if (height >= 640) return 'SD 360p';
|
||||
if (height >= 426) return 'SD 240p';
|
||||
if (height >= 256) return 'SD 144p';
|
||||
return resolution || 'Unknown';
|
||||
}
|
||||
|
||||
function getQualityBadge(height: number): { label: string; color: string } | null {
|
||||
if (height >= 3840) return { label: '4K', color: '#ff0000' };
|
||||
if (height >= 2560) return { label: '2K', color: '#ff6b00' };
|
||||
if (height >= 1920) return { label: 'HD', color: '#00a0ff' };
|
||||
if (height >= 1280) return { label: 'HD', color: '#00a0ff' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function WatchActions({ videoId }: { videoId: string }) {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [showFormats, setShowFormats] = useState(false);
|
||||
const [formats, setFormats] = useState<VideoFormat[]>([]);
|
||||
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([]);
|
||||
const [isLoadingFormats, setIsLoadingFormats] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<string>('');
|
||||
const [progressPercent, setProgressPercent] = useState(0);
|
||||
const [ffmpegLoaded, setFfmpegLoaded] = useState(false);
|
||||
const [ffmpegLoading, setFfmpegLoading] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const ffmpegRef = useRef<any>(null);
|
||||
|
||||
const loadFFmpeg = useCallback(async () => {
|
||||
if (ffmpegLoaded || ffmpegLoading) return;
|
||||
|
||||
setFfmpegLoading(true);
|
||||
setDownloadProgress('Loading video processor...');
|
||||
|
||||
try {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js';
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
|
||||
const coreScript = document.createElement('script');
|
||||
coreScript.src = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js';
|
||||
coreScript.async = true;
|
||||
document.head.appendChild(coreScript);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkLoaded = () => {
|
||||
if (window.FFmpeg && window.FFmpeg.FFmpeg) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkLoaded, 100);
|
||||
}
|
||||
};
|
||||
checkLoaded();
|
||||
});
|
||||
|
||||
const { FFmpeg } = window.FFmpeg;
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
await ffmpeg.load({
|
||||
coreURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js',
|
||||
wasmURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.wasm',
|
||||
});
|
||||
|
||||
ffmpegRef.current = ffmpeg;
|
||||
setFfmpegLoaded(true);
|
||||
} catch (e) {
|
||||
console.error('Failed to load FFmpeg:', e);
|
||||
} finally {
|
||||
setFfmpegLoading(false);
|
||||
}
|
||||
}, [ffmpegLoaded, ffmpegLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowFormats(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleShare = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = window.location.href;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
alert('Link copied to clipboard!');
|
||||
}).catch(() => {
|
||||
alert('Failed to copy link');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFormats = async () => {
|
||||
if (showFormats) {
|
||||
setShowFormats(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowFormats(true);
|
||||
if (formats.length > 0) return;
|
||||
|
||||
setIsLoadingFormats(true);
|
||||
try {
|
||||
const res = await fetch(`/api/formats?v=${encodeURIComponent(videoId)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const videoFormats = data.filter((f: VideoFormat) =>
|
||||
(f.type === 'video' || f.type === 'both') &&
|
||||
!f.format_note?.toLowerCase().includes('storyboard') &&
|
||||
f.ext === 'mp4'
|
||||
).sort((a: VideoFormat, b: VideoFormat) => {
|
||||
const resA = parseInt(a.resolution) || 0;
|
||||
const resB = parseInt(b.resolution) || 0;
|
||||
return resB - resA;
|
||||
});
|
||||
|
||||
const audioOnly = data.filter((f: VideoFormat) =>
|
||||
f.type === 'audio' || (f.resolution === 'audio only')
|
||||
).sort((a: VideoFormat, b: VideoFormat) => (b.filesize || 0) - (a.filesize || 0));
|
||||
|
||||
setFormats(videoFormats.length > 0 ? videoFormats : data.filter((f: VideoFormat) => f.ext === 'mp4').slice(0, 10));
|
||||
setAudioFormats(audioOnly);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch formats:', e);
|
||||
} finally {
|
||||
setIsLoadingFormats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFile = async (url: string, label: string): Promise<Uint8Array> => {
|
||||
setDownloadProgress(`Downloading ${label}...`);
|
||||
|
||||
const tryDirect = async (): Promise<Response | null> => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
const response = await fetch(url, { signal: controller.signal, mode: 'cors' });
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok ? response : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tryProxy = async (): Promise<Response> => {
|
||||
return fetch(`/api/proxy-file?url=${encodeURIComponent(url)}`);
|
||||
};
|
||||
|
||||
let response = await tryDirect();
|
||||
if (!response) {
|
||||
setDownloadProgress(`Connecting via proxy...`);
|
||||
response = await tryProxy();
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${label}`);
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
let loaded = 0;
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No reader available');
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
loaded += value.length;
|
||||
if (total > 0) {
|
||||
const percent = Math.round((loaded / total) * 100);
|
||||
setProgressPercent(percent);
|
||||
setDownloadProgress(`${label}: ${percent}%`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Uint8Array(loaded);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDownload = async (format?: VideoFormat) => {
|
||||
setIsDownloading(true);
|
||||
setShowFormats(false);
|
||||
setProgressPercent(0);
|
||||
setDownloadProgress('Preparing download...');
|
||||
|
||||
try {
|
||||
const needsAudioMerge = format && !format.has_audio && format.type !== 'both';
|
||||
|
||||
if (!needsAudioMerge) {
|
||||
setDownloadProgress('Getting download link...');
|
||||
const res = await fetch(`/api/download?v=${encodeURIComponent(videoId)}${format ? `&f=${encodeURIComponent(format.format_id)}` : ''}`);
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
window.open(data.url, '_blank');
|
||||
setIsDownloading(false);
|
||||
setDownloadProgress('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await loadFFmpeg();
|
||||
|
||||
if (!ffmpegRef.current) {
|
||||
throw new Error('Video processor failed to load. Please try again.');
|
||||
}
|
||||
|
||||
const ffmpeg = ffmpegRef.current;
|
||||
|
||||
setDownloadProgress('Fetching video...');
|
||||
const videoRes = await fetch(`/api/download?v=${encodeURIComponent(videoId)}${format ? `&f=${encodeURIComponent(format.format_id)}` : ''}`);
|
||||
const videoData = await videoRes.json();
|
||||
|
||||
if (!videoData.url) {
|
||||
throw new Error(videoData.error || 'Failed to get video URL');
|
||||
}
|
||||
|
||||
let audioUrl: string | null = null;
|
||||
if (needsAudioMerge && audioFormats.length > 0) {
|
||||
const audioRes = await fetch(`/api/download?v=${encodeURIComponent(videoId)}&f=${encodeURIComponent(audioFormats[0].format_id)}`);
|
||||
const audioData = await audioRes.json();
|
||||
audioUrl = audioData.url;
|
||||
}
|
||||
|
||||
const videoBuffer = await fetchFile(videoData.url, 'Video');
|
||||
|
||||
if (audioUrl) {
|
||||
setProgressPercent(0);
|
||||
const audioBuffer = await fetchFile(audioUrl, 'Audio');
|
||||
|
||||
setDownloadProgress('Merging video & audio...');
|
||||
setProgressPercent(-1);
|
||||
|
||||
const videoExt = format?.ext || 'mp4';
|
||||
await ffmpeg.writeFile(`input.${videoExt}`, videoBuffer);
|
||||
await ffmpeg.writeFile('audio.m4a', audioBuffer);
|
||||
|
||||
await ffmpeg.exec([
|
||||
'-i', `input.${videoExt}`,
|
||||
'-i', 'audio.m4a',
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'aac',
|
||||
'-map', '0:v',
|
||||
'-map', '1:a',
|
||||
'-shortest',
|
||||
'output.mp4'
|
||||
]);
|
||||
|
||||
setDownloadProgress('Saving file...');
|
||||
const mergedData = await ffmpeg.readFile('output.mp4');
|
||||
const mergedBuffer = new Uint8Array(mergedData as ArrayBuffer);
|
||||
|
||||
const qualityLabel = getQualityLabel(format?.resolution || '').replace(/\s/g, '_');
|
||||
const blob = new Blob([mergedBuffer], { type: 'video/mp4' });
|
||||
downloadBlob(blob, `${videoId}_${qualityLabel}.mp4`);
|
||||
|
||||
await ffmpeg.deleteFile(`input.${videoExt}`);
|
||||
await ffmpeg.deleteFile('audio.m4a');
|
||||
await ffmpeg.deleteFile('output.mp4');
|
||||
} else {
|
||||
const qualityLabel = getQualityLabel(format?.resolution || '').replace(/\s/g, '_');
|
||||
const blob = new Blob([new Uint8Array(videoBuffer)], { type: 'video/mp4' });
|
||||
downloadBlob(blob, `${videoId}_${qualityLabel}.mp4`);
|
||||
}
|
||||
|
||||
setDownloadProgress('Download complete!');
|
||||
setProgressPercent(100);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
alert(e.message || 'Download failed. Please try again.');
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsDownloading(false);
|
||||
setDownloadProgress('');
|
||||
setProgressPercent(0);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
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' }}>
|
||||
<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 style={{
|
||||
position: 'absolute',
|
||||
top: '42px',
|
||||
right: 0,
|
||||
backgroundColor: 'var(--yt-background)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: 'var(--yt-shadow-lg)',
|
||||
padding: '8px 0',
|
||||
zIndex: 1000,
|
||||
minWidth: '240px',
|
||||
maxHeight: '360px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid var(--yt-border)',
|
||||
}}>
|
||||
<div style={{ padding: '10px 16px', fontSize: '13px', fontWeight: '600', color: 'var(--yt-text-primary)', borderBottom: '1px solid var(--yt-border)' }}>
|
||||
Select Quality
|
||||
</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>
|
||||
);
|
||||
}
|
||||
239
frontend/app/watch/YouTubePlayer.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
YT: any;
|
||||
onYouTubeIframeAPIReady: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
interface YouTubePlayerProps {
|
||||
videoId: string;
|
||||
title?: string;
|
||||
autoplay?: boolean;
|
||||
onVideoEnd?: () => void;
|
||||
onVideoReady?: () => void;
|
||||
}
|
||||
|
||||
function PlayerSkeleton() {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
backgroundColor: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '12px',
|
||||
}}>
|
||||
<LoadingSpinner color="white" size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function YouTubePlayer({
|
||||
videoId,
|
||||
title,
|
||||
autoplay = true,
|
||||
onVideoEnd,
|
||||
onVideoReady
|
||||
}: YouTubePlayerProps) {
|
||||
const playerRef = useRef<HTMLDivElement>(null);
|
||||
const playerInstanceRef = useRef<any>(null);
|
||||
const [isApiReady, setIsApiReady] = useState(false);
|
||||
const [isPlayerReady, setIsPlayerReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Load YouTube IFrame API
|
||||
useEffect(() => {
|
||||
if (window.YT && window.YT.Player) {
|
||||
setIsApiReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script already exists
|
||||
const existingScript = document.querySelector('script[src*="youtube.com/iframe_api"]');
|
||||
if (existingScript) {
|
||||
// Script exists, wait for it to load
|
||||
const checkYT = setInterval(() => {
|
||||
if (window.YT && window.YT.Player) {
|
||||
setIsApiReady(true);
|
||||
clearInterval(checkYT);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(checkYT);
|
||||
}
|
||||
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://www.youtube.com/iframe_api';
|
||||
tag.async = true;
|
||||
document.head.appendChild(tag);
|
||||
|
||||
window.onYouTubeIframeAPIReady = () => {
|
||||
console.log('YouTube IFrame API ready');
|
||||
setIsApiReady(true);
|
||||
};
|
||||
|
||||
return () => {
|
||||
// Clean up
|
||||
window.onYouTubeIframeAPIReady = () => {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize player when API is ready
|
||||
useEffect(() => {
|
||||
if (!isApiReady || !playerRef.current || !videoId) return;
|
||||
|
||||
// Destroy previous player instance if exists
|
||||
if (playerInstanceRef.current) {
|
||||
try {
|
||||
playerInstanceRef.current.destroy();
|
||||
} catch (e) {
|
||||
console.log('Error destroying player:', e);
|
||||
}
|
||||
playerInstanceRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const player = new window.YT.Player(playerRef.current, {
|
||||
videoId: videoId,
|
||||
playerVars: {
|
||||
autoplay: autoplay ? 1 : 0,
|
||||
controls: 1,
|
||||
rel: 0,
|
||||
modestbranding: 0,
|
||||
playsinline: 1,
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
widget_referrer: window.location.href,
|
||||
iv_load_policy: 3,
|
||||
fs: 0,
|
||||
disablekb: 0,
|
||||
color: 'white',
|
||||
},
|
||||
events: {
|
||||
onReady: (event: any) => {
|
||||
console.log('YouTube Player ready for video:', videoId);
|
||||
setIsPlayerReady(true);
|
||||
if (onVideoReady) onVideoReady();
|
||||
|
||||
// Auto-play if enabled
|
||||
if (autoplay) {
|
||||
try {
|
||||
event.target.playVideo();
|
||||
} catch (e) {
|
||||
console.log('Autoplay prevented:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
onStateChange: (event: any) => {
|
||||
// Video ended
|
||||
if (event.data === window.YT.PlayerState.ENDED) {
|
||||
if (onVideoEnd) {
|
||||
onVideoEnd();
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (event: any) => {
|
||||
console.error('YouTube Player Error:', event.data);
|
||||
setError(`Failed to load video (Error ${event.data})`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
playerInstanceRef.current = player;
|
||||
} catch (error) {
|
||||
console.error('Failed to create YouTube player:', error);
|
||||
setError('Failed to initialize video player');
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (playerInstanceRef.current) {
|
||||
try {
|
||||
playerInstanceRef.current.destroy();
|
||||
} catch (e) {
|
||||
console.log('Error cleaning up player:', e);
|
||||
}
|
||||
playerInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isApiReady, videoId, autoplay]);
|
||||
|
||||
// Handle video end
|
||||
useEffect(() => {
|
||||
if (!isPlayerReady || !onVideoEnd) return;
|
||||
|
||||
const handleVideoEnd = () => {
|
||||
onVideoEnd();
|
||||
};
|
||||
|
||||
// The onStateChange event handler already handles this
|
||||
}, [isPlayerReady, onVideoEnd]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
backgroundColor: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '12px',
|
||||
color: '#fff',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}>
|
||||
<div>{error}</div>
|
||||
<button
|
||||
onClick={() => window.open(`https://www.youtube.com/watch?v=${videoId}`, '_blank')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#ff0000',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Watch on YouTube
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', backgroundColor: '#000', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
{!isPlayerReady && !error && <PlayerSkeleton />}
|
||||
<div
|
||||
ref={playerRef}
|
||||
id={`youtube-player-${videoId}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility function to play a video
|
||||
export function playVideo(videoId: string) {
|
||||
if (window.YT && window.YT.Player) {
|
||||
// Could create a new player instance or use existing one
|
||||
console.log('Playing video:', videoId);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to pause video
|
||||
export function pauseVideo() {
|
||||
// Would need to reference player instance
|
||||
}
|
||||
160
frontend/app/watch/page.tsx
Executable file → Normal file
|
|
@ -1,157 +1,11 @@
|
|||
import VideoPlayer from './VideoPlayer';
|
||||
import Link from 'next/link';
|
||||
import WatchActions from './WatchActions';
|
||||
import SubscribeButton from '../components/SubscribeButton';
|
||||
import { API_BASE } from '../constants';
|
||||
|
||||
interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
channel_id?: string;
|
||||
thumbnail: string;
|
||||
view_count: number;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
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}/hqdefault.jpg`,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRelatedVideos(videoId: string, title: string, uploader: string) {
|
||||
try {
|
||||
const params = new URLSearchParams({ v: videoId, title: title || '', uploader: uploader || '', limit: '15' });
|
||||
const res = await fetch(`${API_BASE}/api/related?${params.toString()}`, { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<VideoData[]>;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
|
||||
if (!v) {
|
||||
return <div style={{ padding: '2rem' }}>No video ID provided</div>;
|
||||
}
|
||||
|
||||
const info = await getVideoInfo(v);
|
||||
const relatedVideos = await getRelatedVideos(v, info?.title || '', info?.uploader || '');
|
||||
const nextVideoId = relatedVideos.length > 0 ? relatedVideos[0].id : undefined;
|
||||
import { Suspense } from 'react';
|
||||
import ClientWatchPage from './ClientWatchPage';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
export default function WatchPage() {
|
||||
return (
|
||||
<div className="watch-container fade-in">
|
||||
<div className="watch-primary">
|
||||
<div className="watch-player-wrapper">
|
||||
<VideoPlayer
|
||||
videoId={v}
|
||||
title={info?.title}
|
||||
nextVideoId={nextVideoId}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div className="watch-secondary">
|
||||
<div className="watch-related-list">
|
||||
{relatedVideos.map((video, i) => {
|
||||
const views = formatViews(video.view_count);
|
||||
const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
|
||||
|
||||
return (
|
||||
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}>
|
||||
<div className="related-thumb-container">
|
||||
<img src={video.thumbnail} alt={video.title} className="related-thumb-img" />
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<LoadingSpinner fullScreen text="Loading video..." />}>
|
||||
<ClientWatchPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
0
frontend/eslint.config.mjs
Executable file → Normal file
1938
frontend/frontend.log
Normal file
12
frontend/next.config.mjs
Executable file → Normal file
|
|
@ -7,17 +7,19 @@ const nextConfig = {
|
|||
protocol: 'https',
|
||||
hostname: 'i.ytimg.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'yt3.ggpht.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
// Backend runs on port 8080 inside the container
|
||||
const apiBase = 'http://localhost:8080';
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://kv-tube-backend:8080/api/:path*',
|
||||
},
|
||||
{
|
||||
source: '/video_proxy',
|
||||
destination: 'http://kv-tube-backend:8080/video_proxy',
|
||||
destination: `${apiBase}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
|
|
|||
0
frontend/package-lock.json
generated
Executable file → Normal file
8
frontend/package.json
Executable file → Normal file
|
|
@ -9,18 +9,12 @@
|
|||
"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",
|
||||
"vidstack": "^1.12.13"
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
0
frontend/postcss.config.mjs
Executable file → Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
0
frontend/public/file.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
0
frontend/public/globe.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
BIN
frontend/public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
frontend/public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
frontend/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
frontend/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
frontend/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/icons/icon-maskable-192x192.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
frontend/public/icons/icon-maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
24
frontend/public/icons/icon.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a1a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#000000;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="playGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#ff0000;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#cc0000;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" rx="96" fill="url(#bgGrad)"/>
|
||||
|
||||
<!-- Play Button Circle -->
|
||||
<circle cx="256" cy="256" r="180" fill="url(#playGrad)"/>
|
||||
|
||||
<!-- Play Triangle -->
|
||||
<path d="M200 140 L380 256 L200 372 Z" fill="white"/>
|
||||
|
||||
<!-- KV Text -->
|
||||
<text x="256" y="440" text-anchor="middle" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white">KV-TUBE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 976 B |
BIN
frontend/public/kv-tube-logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
109
frontend/public/manifest.json
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
{
|
||||
"name": "KV-Tube - Video Streaming",
|
||||
"short_name": "KV-Tube",
|
||||
"description": "A modern YouTube-like video streaming platform with background playback",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#ff0000",
|
||||
"scope": "/",
|
||||
"lang": "en",
|
||||
"categories": ["entertainment", "music", "video"],
|
||||
"prefer_related_applications": false,
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Trending Videos",
|
||||
"short_name": "Trending",
|
||||
"description": "View trending videos",
|
||||
"url": "/?trending=true",
|
||||
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Search",
|
||||
"short_name": "Search",
|
||||
"description": "Search for videos",
|
||||
"url": "/search",
|
||||
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshots/desktop.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "KV-Tube Desktop View"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/mobile.png",
|
||||
"sizes": "750x1334",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow",
|
||||
"label": "KV-Tube Mobile View"
|
||||
}
|
||||
]
|
||||
}
|
||||
0
frontend/public/next.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
83
frontend/public/splash.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KV-Tube</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.splash {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 20px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.8; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #ff0000;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="splash">
|
||||
<svg class="logo" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#1a1a1a"/>
|
||||
<circle cx="256" cy="256" r="180" fill="#ff0000"/>
|
||||
<path d="M200 140 L380 256 L200 372 Z" fill="white"/>
|
||||
</svg>
|
||||
<h1 class="title">KV-Tube</h1>
|
||||
<p class="subtitle">Loading your videos...</p>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Redirect to main app after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
94
frontend/public/sw.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// KV-Tube Service Worker for Background Playback
|
||||
const CACHE_NAME = 'kvtube-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/icon-192x192.png',
|
||||
'/icon-512x512.png'
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - network first, fallback to cache
|
||||
self.addEventListener('fetch', (e) => {
|
||||
// Skip non-GET requests
|
||||
if (e.request.method !== 'GET') return;
|
||||
|
||||
// Skip API and video requests
|
||||
if (e.request.url.includes('/api/') ||
|
||||
e.request.url.includes('googlevideo.com') ||
|
||||
e.request.url.includes('youtube.com')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.respondWith(
|
||||
fetch(e.request)
|
||||
.then((response) => {
|
||||
// Cache successful responses
|
||||
if (response.ok) {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(e.request, responseClone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(e.request).then((cachedResponse) => {
|
||||
return cachedResponse || new Response('Offline', { status: 503 });
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync for audio playback
|
||||
self.addEventListener('message', (e) => {
|
||||
if (e.data && e.data.type === 'BACKGROUND_AUDIO') {
|
||||
// Notify all clients about background audio state
|
||||
self.clients.matchAll().then((clients) => {
|
||||
clients.forEach((client) => {
|
||||
client.postMessage({
|
||||
type: 'BACKGROUND_AUDIO_STATE',
|
||||
isPlaying: e.data.isPlaying
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle media session actions for background control
|
||||
self.addEventListener('message', (e) => {
|
||||
if (e.data && e.data.type === 'MEDIA_SESSION_ACTION') {
|
||||
self.clients.matchAll().then((clients) => {
|
||||
clients.forEach((client) => {
|
||||
client.postMessage({
|
||||
type: 'MEDIA_SESSION_ACTION',
|
||||
action: e.data.action,
|
||||
data: e.data.data
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
0
frontend/public/vercel.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
0
frontend/public/window.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
63
frontend/scripts/generate-icons.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
|
||||
const svgPath = path.join(__dirname, '../public/icons/icon.svg');
|
||||
const outputDir = path.join(__dirname, '../public/icons');
|
||||
|
||||
async function generateIcons() {
|
||||
try {
|
||||
// Read SVG file
|
||||
const svgBuffer = fs.readFileSync(svgPath);
|
||||
|
||||
// Generate icons for each size
|
||||
for (const size of sizes) {
|
||||
await sharp(svgBuffer)
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toFile(path.join(outputDir, `icon-${size}x${size}.png`));
|
||||
|
||||
console.log(`Generated icon-${size}x${size}.png`);
|
||||
}
|
||||
|
||||
// Generate maskable icons (with padding)
|
||||
const maskableSizes = [192, 512];
|
||||
for (const size of maskableSizes) {
|
||||
const padding = Math.floor(size * 0.1); // 10% padding for maskable
|
||||
|
||||
await sharp(svgBuffer)
|
||||
.resize(size - (padding * 2), size - (padding * 2))
|
||||
.extend({
|
||||
top: padding,
|
||||
bottom: padding,
|
||||
left: padding,
|
||||
right: padding,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||
})
|
||||
.png()
|
||||
.toFile(path.join(outputDir, `icon-maskable-${size}x${size}.png`));
|
||||
|
||||
console.log(`Generated icon-maskable-${size}x${size}.png`);
|
||||
}
|
||||
|
||||
// Copy main icons to public root for backward compatibility
|
||||
await sharp(svgBuffer)
|
||||
.resize(192, 192)
|
||||
.png()
|
||||
.toFile(path.join(__dirname, '../public/icon-192x192.png'));
|
||||
|
||||
await sharp(svgBuffer)
|
||||
.resize(512, 512)
|
||||
.png()
|
||||
.toFile(path.join(__dirname, '../public/icon-512x512.png'));
|
||||
|
||||
console.log('All icons generated successfully!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating icons:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateIcons();
|
||||