Compare commits
No commits in common. "8844007f18947106e391b393c033521352cc0466" and "b7ea9165a14add936bb77e2cd649ea0a12e608ff" have entirely different histories.
8844007f18
...
b7ea9165a1
0
.dockerignore
Normal file → Executable file
19
.env.example
Normal file → Executable file
|
|
@ -9,22 +9,3 @@ KVTUBE_DATA_DIR=./data
|
||||||
|
|
||||||
# Gin mode: debug or release
|
# Gin mode: debug or release
|
||||||
GIN_MODE=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
|
|
||||||
|
|
|
||||||
0
.github/workflows/ci.yml
vendored
Normal file → Executable file
0
.github/workflows/docker-publish.yml
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
|
|
@ -1 +0,0 @@
|
||||||
cat: can't open '/app/frontend/.next/required-server-files.js': No such file or directory
|
|
||||||
|
|
@ -5,7 +5,7 @@ RUN apk add --no-cache git gcc musl-dev
|
||||||
COPY backend/go.mod backend/go.sum ./
|
COPY backend/go.mod backend/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o kv-tube .
|
RUN CGO_ENABLED=1 GOOS=linux go build -o kv-tube .
|
||||||
|
|
||||||
# ---- Frontend Builder ----
|
# ---- Frontend Builder ----
|
||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:20-alpine AS frontend-deps
|
||||||
|
|
@ -19,8 +19,7 @@ WORKDIR /app
|
||||||
COPY --from=frontend-deps /app/node_modules ./node_modules
|
COPY --from=frontend-deps /app/node_modules ./node_modules
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
ENV NEXT_PUBLIC_API_URL=http://localhost:8080
|
RUN npm run build
|
||||||
RUN echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" && npm run build
|
|
||||||
|
|
||||||
# ---- Final Unified Image ----
|
# ---- Final Unified Image ----
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,7 @@ A modern, fast, and fully-featured YouTube-like video streaming platform. Built
|
||||||
- **Modern Video Player**: High-resolution video playback with HLS support and quality selection.
|
- **Modern Video Player**: High-resolution video playback with HLS support and quality selection.
|
||||||
- **Fast Navigation**: Instant click feedback with skeleton loaders for related videos.
|
- **Fast Navigation**: Instant click feedback with skeleton loaders for related videos.
|
||||||
- **Infinite Scrolling**: Scroll seamlessly through a dynamic video grid on the homepage.
|
- **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.
|
- **Watch History & Suggestions**: Keep track of what you've watched, with smart video suggestions.
|
||||||
- **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).
|
- **Region Selection**: Tailor your content to specific regions (e.g., Vietnam).
|
||||||
- **Responsive Design**: Beautiful, mobile-friendly interface with light and dark theme support.
|
- **Responsive Design**: Beautiful, mobile-friendly interface with light and dark theme support.
|
||||||
- **Containerized**: Fully Dockerized for easy setup using `docker-compose`.
|
- **Containerized**: Fully Dockerized for easy setup using `docker-compose`.
|
||||||
|
|
@ -38,7 +34,7 @@ version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
kv-tube-app:
|
kv-tube-app:
|
||||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.6
|
image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.1
|
||||||
container_name: kv-tube-app
|
container_name: kv-tube-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
0
backend/Dockerfile
Normal file → Executable file
34
backend/go.mod
Normal file → Executable file
|
|
@ -1,50 +1,42 @@
|
||||||
module kvtube-go
|
module kvtube-go
|
||||||
|
|
||||||
go 1.25.0
|
go 1.24
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/gin-gonic/gin v1.11.0
|
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
modernc.org/sqlite v1.47.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/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/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // 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
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.27.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // 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
Normal file → Executable file
|
|
@ -5,18 +5,13 @@ 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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
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.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/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 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
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 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
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=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
|
@ -27,15 +22,7 @@ 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-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 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
|
@ -46,22 +33,21 @@ 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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
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 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
|
@ -69,62 +55,33 @@ 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.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.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.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
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 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
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/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.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=
|
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=
|
|
||||||
|
|
|
||||||
0
backend/main.go
Normal file → Executable file
|
|
@ -1,91 +0,0 @@
|
||||||
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
Normal file → Executable file
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var DB *sql.DB
|
var DB *sql.DB
|
||||||
|
|
@ -22,7 +22,7 @@ func InitDB() {
|
||||||
}
|
}
|
||||||
|
|
||||||
dbPath := filepath.Join(dataDir, "kvtube.db")
|
dbPath := filepath.Join(dataDir, "kvtube.db")
|
||||||
db, err := sql.Open("sqlite", dbPath)
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to open database: %v", err)
|
log.Fatalf("Failed to open database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -68,21 +68,8 @@ func InitDB() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create performance indexes
|
// Insert default user for history tracking
|
||||||
indexes := []string{
|
_, err = db.Exec(`INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'default_user', 'password')`)
|
||||||
`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 {
|
if err != nil {
|
||||||
log.Printf("Failed to insert default user: %v", err)
|
log.Printf("Failed to insert default user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
190
backend/routes/api.go
Normal file → Executable file
|
|
@ -2,114 +2,24 @@ package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"kvtube-go/services"
|
"kvtube-go/services"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAllowedDomain checks if the URL belongs to allowed domains (YouTube/Google)
|
|
||||||
func isAllowedDomain(targetURL string) error {
|
|
||||||
parsedURL, err := url.Parse(targetURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allowed domains for video proxy
|
|
||||||
allowedDomains := []string{
|
|
||||||
".youtube.com",
|
|
||||||
".googlevideo.com",
|
|
||||||
".ytimg.com",
|
|
||||||
".google.com",
|
|
||||||
".gstatic.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
host := strings.ToLower(parsedURL.Hostname())
|
|
||||||
|
|
||||||
// Check if host matches any allowed domain
|
|
||||||
for _, domain := range allowedDomains {
|
|
||||||
if strings.HasSuffix(host, domain) || host == strings.TrimPrefix(domain, ".") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("domain %s not allowed", host)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateSearchQuery ensures search query contains only safe characters
|
|
||||||
func validateSearchQuery(query string) error {
|
|
||||||
// Allow alphanumeric, spaces, hyphens, underscores, dots, commas, exclamation marks
|
|
||||||
safePattern := regexp.MustCompile(`^[a-zA-Z0-9\s\-_.,!]+$`)
|
|
||||||
if !safePattern.MatchString(query) {
|
|
||||||
return fmt.Errorf("search query contains invalid characters")
|
|
||||||
}
|
|
||||||
if len(query) > 200 {
|
|
||||||
return fmt.Errorf("search query too long")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global HTTP client with connection pooling and timeouts
|
|
||||||
var httpClient = &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
MaxIdleConns: 100,
|
|
||||||
MaxIdleConnsPerHost: 10,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetupRouter() *gin.Engine {
|
func SetupRouter() *gin.Engine {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
// CORS middleware - restrict to specific origins from environment variable
|
|
||||||
allowedOrigins := getAllowedOrigins()
|
|
||||||
r.Use(func(c *gin.Context) {
|
r.Use(func(c *gin.Context) {
|
||||||
origin := c.GetHeader("Origin")
|
c.Writer.Header().Set("Access-Control-Allow-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-Headers", "Content-Type, Authorization")
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
if c.Request.Method == "OPTIONS" {
|
||||||
c.AbortWithStatus(204)
|
c.AbortWithStatus(204)
|
||||||
return
|
return
|
||||||
|
|
@ -128,7 +38,6 @@ func SetupRouter() *gin.Engine {
|
||||||
api.GET("/trending", handleTrending)
|
api.GET("/trending", handleTrending)
|
||||||
api.GET("/get_stream_info", handleGetStreamInfo)
|
api.GET("/get_stream_info", handleGetStreamInfo)
|
||||||
api.GET("/download", handleDownload)
|
api.GET("/download", handleDownload)
|
||||||
api.GET("/download-file", handleDownloadFile)
|
|
||||||
api.GET("/transcript", handleTranscript)
|
api.GET("/transcript", handleTranscript)
|
||||||
api.GET("/comments", handleComments)
|
api.GET("/comments", handleComments)
|
||||||
api.GET("/channel/videos", handleChannelVideos)
|
api.GET("/channel/videos", handleChannelVideos)
|
||||||
|
|
@ -162,12 +71,6 @@ func handleSearch(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate search query for security
|
|
||||||
if err := validateSearchQuery(query); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := 20
|
limit := 20
|
||||||
if l := c.Query("limit"); l != "" {
|
if l := c.Query("limit"); l != "" {
|
||||||
if parsed, err := strconv.Atoi(l); err == nil {
|
if parsed, err := strconv.Atoi(l); err == nil {
|
||||||
|
|
@ -288,88 +191,6 @@ func handleDownload(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, info)
|
c.JSON(http.StatusOK, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDownloadFile(c *gin.Context) {
|
|
||||||
videoID := c.Query("v")
|
|
||||||
if videoID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
formatID := c.Query("f")
|
|
||||||
|
|
||||||
// Get the download URL from yt-dlp
|
|
||||||
info, err := services.GetDownloadURL(videoID, formatID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("GetDownloadURL Error: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.URL == "" {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "No download URL available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create request to the video URL
|
|
||||||
req, err := http.NewRequest("GET", info.URL, nil)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy range header if present (for partial content/resumable downloads)
|
|
||||||
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
|
|
||||||
req.Header.Set("Range", rangeHeader)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set appropriate headers for YouTube
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
|
||||||
req.Header.Set("Referer", "https://www.youtube.com/")
|
|
||||||
req.Header.Set("Origin", "https://www.youtube.com")
|
|
||||||
|
|
||||||
// Make the request
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to fetch video: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Copy relevant headers from YouTube response to our response
|
|
||||||
for key, values := range resp.Header {
|
|
||||||
if key == "Content-Type" || key == "Content-Length" || key == "Content-Range" ||
|
|
||||||
key == "Accept-Ranges" || key == "Content-Disposition" {
|
|
||||||
for _, value := range values {
|
|
||||||
c.Header(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set content type based on extension
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
|
||||||
if contentType == "" {
|
|
||||||
if info.Ext == "mp4" {
|
|
||||||
contentType = "video/mp4"
|
|
||||||
} else if info.Ext == "webm" {
|
|
||||||
contentType = "video/webm"
|
|
||||||
} else {
|
|
||||||
contentType = "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Header("Content-Type", contentType)
|
|
||||||
|
|
||||||
// Set content disposition for download
|
|
||||||
filename := fmt.Sprintf("%s.%s", info.Title, info.Ext)
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
||||||
|
|
||||||
// Copy status code
|
|
||||||
c.Status(resp.StatusCode)
|
|
||||||
|
|
||||||
// Stream the video
|
|
||||||
io.Copy(c.Writer, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGetFormats(c *gin.Context) {
|
func handleGetFormats(c *gin.Context) {
|
||||||
videoID := c.Query("v")
|
videoID := c.Query("v")
|
||||||
if videoID == "" {
|
if videoID == "" {
|
||||||
|
|
@ -598,12 +419,6 @@ func handleVideoProxy(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF Protection: Validate target domain
|
|
||||||
if err := isAllowedDomain(targetURL); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "URL domain not allowed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", targetURL, nil)
|
req, err := http.NewRequest("GET", targetURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
|
||||||
|
|
@ -619,7 +434,8 @@ func handleVideoProxy(c *gin.Context) {
|
||||||
req.Header.Set("Range", rangeHeader)
|
req.Header.Set("Range", rangeHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
0
backend/services/history.go
Normal file → Executable file
0
backend/services/subscription.go
Normal file → Executable file
200
backend/services/ytdlp.go
Normal file → Executable file
|
|
@ -7,44 +7,9 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sort"
|
|
||||||
"strings"
|
"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 {
|
type VideoData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|
@ -124,66 +89,6 @@ 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
|
// RunYtDlp securely executes yt-dlp with the given arguments and returns JSON output
|
||||||
func RunYtDlp(args ...string) ([]byte, error) {
|
func RunYtDlp(args ...string) ([]byte, error) {
|
||||||
cmdArgs := append([]string{
|
cmdArgs := append([]string{
|
||||||
|
|
@ -195,7 +100,27 @@ 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",
|
"--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...)
|
}, args...)
|
||||||
|
|
||||||
cmd := exec.Command(ytDlpBinPath, cmdArgs...)
|
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...)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
@ -251,8 +176,7 @@ func GetVideoInfo(videoID string) (*VideoData, error) {
|
||||||
url,
|
url,
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := "video_info:" + videoID
|
out, err := RunYtDlp(args...)
|
||||||
out, err := RunYtDlpCached(cacheKey, 3600, args...) // Cache for 1 hour
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -285,19 +209,44 @@ type QualityFormat struct {
|
||||||
func GetVideoQualities(videoID string) ([]QualityFormat, error) {
|
func GetVideoQualities(videoID string) ([]QualityFormat, error) {
|
||||||
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
||||||
|
|
||||||
cmdArgs := []string{
|
cmdArgs := append([]string{
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
"--force-ipv4",
|
"--force-ipv4",
|
||||||
"--no-playlist",
|
"--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",
|
"--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,
|
}, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := "video_qualities:" + videoID
|
cmd := exec.Command(binPath, cmdArgs...)
|
||||||
out, err := RunYtDlpCached(cacheKey, 3600, cmdArgs...) // Cache for 1 hour
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("yt-dlp error: %v, stderr: %s", err, stderr.String())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,7 +266,7 @@ func GetVideoQualities(videoID string) ([]QualityFormat, error) {
|
||||||
} `json:"formats"`
|
} `json:"formats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(out, &raw); err != nil {
|
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -404,9 +353,13 @@ func GetVideoQualities(videoID string) ([]QualityFormat, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by height descending
|
// Sort by height descending
|
||||||
sort.Slice(qualities, func(i, j int) bool {
|
for i := range qualities {
|
||||||
return qualities[i].Height > qualities[j].Height
|
for j := i + 1; j < len(qualities); j++ {
|
||||||
})
|
if qualities[j].Height > qualities[i].Height {
|
||||||
|
qualities[i], qualities[j] = qualities[j], qualities[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return qualities, nil
|
return qualities, nil
|
||||||
}
|
}
|
||||||
|
|
@ -755,6 +708,7 @@ func GetDownloadURL(videoID string, formatID string) (*DownloadInfo, error) {
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"--format", formatArgs,
|
"--format", formatArgs,
|
||||||
|
"--dump-json",
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
url,
|
url,
|
||||||
}
|
}
|
||||||
|
|
@ -900,7 +854,7 @@ func GetChannelInfo(channelID string) (*ChannelInfo, error) {
|
||||||
return nil, fmt.Errorf("no output from yt-dlp")
|
return nil, fmt.Errorf("no output from yt-dlp")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(out, &raw); err != nil {
|
if err := json.Unmarshal([]byte(lines[0]), &raw); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -983,18 +937,40 @@ func GetComments(videoID string, limit int) ([]Comment, error) {
|
||||||
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
||||||
|
|
||||||
cmdArgs := []string{
|
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",
|
"--dump-json",
|
||||||
"--no-download",
|
"--no-download",
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"--write-comments",
|
"--write-comments",
|
||||||
"--extractor-args", fmt.Sprintf("youtube:comment_sort=top;max_comments=%d", limit),
|
fmt.Sprintf("--comment-limit=%d", limit),
|
||||||
url,
|
url,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(ytDlpBinPath, cmdArgs...)
|
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...)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
|
||||||
0
doc/Product Requirements Document (PRD) - KV-Tube
Normal file → Executable file
|
|
@ -1,16 +0,0 @@
|
||||||
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
|
|
||||||
2
docker-compose.yml
Normal file → Executable file
|
|
@ -5,7 +5,7 @@ version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
kv-tube-app:
|
kv-tube-app:
|
||||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.7
|
image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.1
|
||||||
container_name: kv-tube-app
|
container_name: kv-tube-app
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
0
frontend/.gitignore
vendored
Normal file → Executable file
0
frontend/README.md
Normal file → Executable file
116
frontend/app/actions.ts
Normal file → Executable file
|
|
@ -36,30 +36,7 @@ export async function getSuggestedVideos(limit: number = 20): Promise<VideoData[
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRelatedVideos(videoId: string, limit: number = 10): Promise<VideoData[]> {
|
export async function fetchMoreVideos(currentCategory: string, regionLabel: string, page: number): 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';
|
const isAllCategory = currentCategory === 'All';
|
||||||
let newVideos: VideoData[] = [];
|
let newVideos: VideoData[] = [];
|
||||||
|
|
||||||
|
|
@ -68,44 +45,14 @@ export async function fetchMoreVideos(currentCategory: string, regionLabel: stri
|
||||||
const modifier = page < pageModifiers.length ? pageModifiers[page] : `page ${page}`;
|
const modifier = page < pageModifiers.length ? pageModifiers[page] : `page ${page}`;
|
||||||
|
|
||||||
if (isAllCategory) {
|
if (isAllCategory) {
|
||||||
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);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => {
|
const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => {
|
||||||
const q = addRegion(sec.query, regionLabel) + " " + modifier;
|
const q = addRegion(sec.query, regionLabel) + " " + modifier;
|
||||||
|
// Fetch fewer items per section on subsequent pages to mitigate loading times
|
||||||
return await getSearchVideos(q, 5);
|
return await getSearchVideos(q, 5);
|
||||||
});
|
});
|
||||||
const results = await Promise.all(promises);
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
// Interleave the results
|
||||||
const maxLen = Math.max(...results.map(arr => arr.length));
|
const maxLen = Math.max(...results.map(arr => arr.length));
|
||||||
const interleavedList: VideoData[] = [];
|
const interleavedList: VideoData[] = [];
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
|
|
@ -122,45 +69,6 @@ export async function fetchMoreVideos(currentCategory: string, regionLabel: stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newVideos = interleavedList;
|
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') {
|
} else if (currentCategory === 'Watched') {
|
||||||
// Fetch from history, offset by page if desired (backend doesn't support offset yet, so just increase limit)
|
// Fetch from history, offset by page if desired (backend doesn't support offset yet, so just increase limit)
|
||||||
// If the backend returned all items, we'd normally paginate here. For now just mock it or return empty array to prevent infinite duplicating history scroll
|
// If the backend returned all items, we'd normally paginate here. For now just mock it or return empty array to prevent infinite duplicating history scroll
|
||||||
|
|
@ -177,21 +85,3 @@ export async function fetchMoreVideos(currentCategory: string, regionLabel: stri
|
||||||
|
|
||||||
return newVideos;
|
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[]> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/comments?v=${videoId}&limit=${limit}`, { cache: 'no-store' });
|
|
||||||
if (!res.ok) return [];
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
20
frontend/app/api/download/route.ts
Normal file → Executable file
|
|
@ -13,28 +13,18 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${API_BASE}/api/download-file?v=${encodeURIComponent(videoId)}${formatId ? `&f=${encodeURIComponent(formatId)}` : ''}`;
|
const url = `${API_BASE}/api/download?v=${encodeURIComponent(videoId)}${formatId ? `&f=${encodeURIComponent(formatId)}` : ''}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
return NextResponse.json({ error: data.error || 'Download failed' }, { status: 500 });
|
||||||
return NextResponse.json({ error: data.error || 'Download failed' }, { status: res.status });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the file directly
|
return NextResponse.json(data);
|
||||||
const headers = new Headers();
|
|
||||||
const contentType = res.headers.get('content-type');
|
|
||||||
const contentDisposition = res.headers.get('content-disposition');
|
|
||||||
|
|
||||||
if (contentType) headers.set('content-type', contentType);
|
|
||||||
if (contentDisposition) headers.set('content-disposition', contentDisposition);
|
|
||||||
|
|
||||||
return new NextResponse(res.body, {
|
|
||||||
status: res.status,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: 'Failed to get download link' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to get download link' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
0
frontend/app/api/formats/route.ts
Normal file → Executable file
|
|
@ -1,35 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { video_id, title, thumbnail } = body;
|
|
||||||
|
|
||||||
if (!video_id) {
|
|
||||||
return NextResponse.json({ error: 'No video ID' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/api/history`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
video_id,
|
|
||||||
title: title || `Video ${video_id}`,
|
|
||||||
thumbnail: thumbnail || `https://i.ytimg.com/vi/${video_id}/hqdefault.jpg`,
|
|
||||||
}),
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json({ error: 'Failed to record history' }, { status: res.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
return NextResponse.json({ success: true, ...data });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API /api/history POST error:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to record history' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
frontend/app/api/proxy-file/route.ts
Normal file → Executable file
|
|
@ -11,8 +11,6 @@ export async function GET(request: NextRequest) {
|
||||||
const res = await fetch(fileUrl, {
|
const res = await fetch(fileUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
'Referer': 'https://www.youtube.com/',
|
|
||||||
'Origin': 'https://www.youtube.com',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
0
frontend/app/api/proxy-stream/route.ts
Normal file → Executable file
0
frontend/app/api/stream/route.ts
Normal file → Executable file
3
frontend/app/api/subscribe/route.ts
Normal file → Executable file
|
|
@ -28,7 +28,7 @@ export async function GET(request: NextRequest) {
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { channel_id, channel_name, channel_avatar } = body;
|
const { channel_id, channel_name } = body;
|
||||||
|
|
||||||
if (!channel_id) {
|
if (!channel_id) {
|
||||||
return NextResponse.json({ error: 'No channel ID' }, { status: 400 });
|
return NextResponse.json({ error: 'No channel ID' }, { status: 400 });
|
||||||
|
|
@ -40,7 +40,6 @@ export async function POST(request: NextRequest) {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
channel_id,
|
channel_id,
|
||||||
channel_name: channel_name || channel_id,
|
channel_name: channel_name || channel_id,
|
||||||
channel_avatar: channel_avatar || '?',
|
|
||||||
}),
|
}),
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
0
frontend/app/channel/[id]/page.tsx
Normal file → Executable file
|
|
@ -1,70 +0,0 @@
|
||||||
'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: 'Subscriptions', 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
Normal file → Executable file
|
|
@ -3,10 +3,9 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack, IoMenuOutline } from 'react-icons/io5';
|
import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack } from 'react-icons/io5';
|
||||||
import RegionSelector from './RegionSelector';
|
import RegionSelector from './RegionSelector';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { useSidebar } from '../context/SidebarContext';
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
@ -16,7 +15,6 @@ export default function Header() {
|
||||||
const mobileInputRef = useRef<HTMLInputElement>(null);
|
const mobileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { toggleSidebar, toggleMobileMenu } = useSidebar();
|
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -39,12 +37,6 @@ export default function Header() {
|
||||||
<>
|
<>
|
||||||
{/* Left */}
|
{/* Left */}
|
||||||
<div className="yt-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' }}>
|
<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>
|
<span style={{ fontSize: '18px', fontWeight: '700', letterSpacing: '-0.5px', fontFamily: 'YouTube Sans, Roboto, Arial, sans-serif' }} className="hidden-mobile">KV-Tube</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
7
frontend/app/components/InfiniteVideoGrid.tsx
Normal file → Executable file
|
|
@ -9,10 +9,9 @@ interface Props {
|
||||||
initialVideos: VideoData[];
|
initialVideos: VideoData[];
|
||||||
currentCategory: string;
|
currentCategory: string;
|
||||||
regionLabel: string;
|
regionLabel: string;
|
||||||
contextVideoId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel, contextVideoId }: Props) {
|
export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel }: Props) {
|
||||||
const [videos, setVideos] = useState<VideoData[]>(initialVideos);
|
const [videos, setVideos] = useState<VideoData[]>(initialVideos);
|
||||||
const [page, setPage] = useState(2);
|
const [page, setPage] = useState(2);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
@ -31,7 +30,7 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newVideos = await fetchMoreVideos(currentCategory, regionLabel, page, contextVideoId);
|
const newVideos = await fetchMoreVideos(currentCategory, regionLabel, page);
|
||||||
if (newVideos.length === 0) {
|
if (newVideos.length === 0) {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -56,7 +55,7 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentCategory, regionLabel, page, isLoading, hasMore, contextVideoId]);
|
}, [currentCategory, regionLabel, page, isLoading, hasMore]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
2
frontend/app/components/MobileNav.tsx
Normal file → Executable file
|
|
@ -10,7 +10,7 @@ export default function MobileNav() {
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||||
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
{ icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
||||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
0
frontend/app/components/RegionSelector.tsx
Normal file → Executable file
9
frontend/app/components/Sidebar.tsx
Normal file → Executable file
|
|
@ -4,24 +4,19 @@ import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md';
|
import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md';
|
||||||
import { SiYoutubeshorts } from 'react-icons/si';
|
import { SiYoutubeshorts } from 'react-icons/si';
|
||||||
import { useSidebar } from '../context/SidebarContext';
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isSidebarOpen } = useSidebar();
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||||
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
{ icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||||
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
|
||||||
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside className="yt-sidebar-mini">
|
||||||
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) => {
|
{navItems.map((item) => {
|
||||||
const isActive = pathname === item.path;
|
const isActive = pathname === item.path;
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
1
frontend/app/components/SubscribeButton.tsx
Normal file → Executable file
|
|
@ -41,7 +41,6 @@ export default function SubscribeButton({ channelId, channelName, initialSubscri
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
channel_id: channelId,
|
channel_id: channelId,
|
||||||
channel_name: channelName || channelId,
|
channel_name: channelName || channelId,
|
||||||
channel_avatar: channelName ? channelName[0].toUpperCase() : '?',
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
|
||||||
37
frontend/app/components/VideoCard.tsx
Normal file → Executable file
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface VideoData {
|
interface VideoData {
|
||||||
|
|
@ -13,8 +12,6 @@ interface VideoData {
|
||||||
view_count: number;
|
view_count: number;
|
||||||
duration: string;
|
duration: string;
|
||||||
uploaded_date?: string;
|
uploaded_date?: string;
|
||||||
list_id?: string;
|
|
||||||
is_mix?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatViews(views: number): string {
|
function formatViews(views: number): string {
|
||||||
|
|
@ -29,48 +26,30 @@ function getRelativeTime(id: string): string {
|
||||||
return times[index];
|
return times[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
import { memo } from 'react';
|
export default function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
|
||||||
|
|
||||||
function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
|
|
||||||
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
|
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
|
||||||
const [isNavigating, setIsNavigating] = useState(false);
|
const [isNavigating, setIsNavigating] = useState(false);
|
||||||
const destination = video.list_id ? `/watch?v=${video.id}&list=${video.list_id}` : `/watch?v=${video.id}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', marginBottom: '12px' }} className="videocard-container">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', marginBottom: '12px' }} className="videocard-container">
|
||||||
<Link
|
<Link
|
||||||
href={destination}
|
href={`/watch?v=${video.id}`}
|
||||||
onClick={() => setIsNavigating(true)}
|
onClick={() => setIsNavigating(true)}
|
||||||
style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}
|
style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}
|
||||||
>
|
>
|
||||||
<Image
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
src={video.thumbnail}
|
src={video.thumbnail}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
fill
|
style={{ width: '100%', height: '100%', objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
||||||
style={{ objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
|
|
||||||
className="videocard-thumb"
|
className="videocard-thumb"
|
||||||
priority={false}
|
|
||||||
/>
|
/>
|
||||||
{video.duration && !video.is_mix && (
|
{video.duration && (
|
||||||
<div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
|
<div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
|
||||||
{video.duration}
|
{video.duration}
|
||||||
</div>
|
</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 && (
|
{isNavigating && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
|
@ -92,7 +71,7 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
|
||||||
<div style={{ display: 'flex', gap: '12px', padding: '0 12px' }} className="videocard-info">
|
<div style={{ display: 'flex', gap: '12px', padding: '0 12px' }} className="videocard-info">
|
||||||
{/* Video Info */}
|
{/* Video Info */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
<Link href={destination} style={{ textDecoration: 'none' }}>
|
<Link href={`/watch?v=${video.id}`} 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' }}>
|
<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}
|
{video.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -116,5 +95,3 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(VideoCard);
|
|
||||||
|
|
|
||||||
2
frontend/app/constants.ts
Normal file → Executable file
|
|
@ -8,8 +8,6 @@ export interface VideoData {
|
||||||
view_count: number;
|
view_count: number;
|
||||||
duration: string;
|
duration: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
list_id?: string;
|
|
||||||
is_mix?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CATEGORY_MAP: Record<string, string> = {
|
export const CATEGORY_MAP: Record<string, string> = {
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
'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
Normal file → Executable file
0
frontend/app/favicon.ico
Normal file → Executable file
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
9
frontend/app/feed/library/page.tsx
Normal file → Executable file
|
|
@ -1,8 +1,5 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
export const revalidate = 0;
|
|
||||||
|
|
||||||
interface VideoData {
|
interface VideoData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -23,8 +20,7 @@ async function getHistory() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/history?limit=20`, { cache: 'no-store' });
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/history?limit=20`, { cache: 'no-store' });
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
return res.json() as Promise<VideoData[]>;
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -34,8 +30,7 @@ async function getSubscriptions() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
return res.json() as Promise<Subscription[]>;
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
frontend/app/feed/subscriptions/page.tsx
Normal file → Executable file
|
|
@ -1,8 +1,5 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
export const revalidate = 0;
|
|
||||||
|
|
||||||
interface VideoData {
|
interface VideoData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -24,8 +21,7 @@ async function getSubscriptions() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
return res.json() as Promise<Subscription[]>;
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -35,8 +31,7 @@ async function getChannelVideos(channelId: string, limit: number = 5) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/videos?id=${channelId}&limit=${limit}`, { cache: 'no-store' });
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/videos?id=${channelId}&limit=${limit}`, { cache: 'no-store' });
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
return res.json() as Promise<VideoData[]>;
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
297
frontend/app/globals.css
Normal file → Executable file
|
|
@ -255,20 +255,6 @@
|
||||||
gap: 4px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.yt-sidebar-mini {
|
.yt-sidebar-mini {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -277,7 +263,7 @@
|
||||||
|
|
||||||
.yt-main-content {
|
.yt-main-content {
|
||||||
margin-top: var(--yt-header-height);
|
margin-top: var(--yt-header-height);
|
||||||
margin-left: 0;
|
margin-left: var(--yt-sidebar-width-mini);
|
||||||
min-height: calc(100vh - var(--yt-header-height));
|
min-height: calc(100vh - var(--yt-header-height));
|
||||||
background-color: var(--yt-background);
|
background-color: var(--yt-background);
|
||||||
}
|
}
|
||||||
|
|
@ -633,237 +619,41 @@ a {
|
||||||
animation: fadeIn 0.2s ease-out;
|
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 PAGE ===== */
|
||||||
|
|
||||||
.watch-container {
|
.watch-container {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 402px;
|
|
||||||
grid-template-rows: auto auto;
|
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
max-width: 1750px;
|
max-width: 1750px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px 40px;
|
padding: 24px;
|
||||||
box-sizing: border-box;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watch-primary {
|
.watch-primary {
|
||||||
grid-column: 1;
|
flex: 1;
|
||||||
grid-row: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watch-container > .comments-section {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 2;
|
|
||||||
padding-right: 16px; /* Space before sidebar */
|
|
||||||
}
|
|
||||||
|
|
||||||
.watch-secondary {
|
.watch-secondary {
|
||||||
grid-column: 2;
|
width: 402px;
|
||||||
grid-row: 1 / span 2;
|
flex-shrink: 0;
|
||||||
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;
|
position: sticky;
|
||||||
top: calc(var(--yt-header-height) + 24px);
|
top: 80px;
|
||||||
height: calc(100vh - var(--yt-header-height) - 48px);
|
max-height: calc(100vh - 104px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin; /* Clean scrollbar appearance */
|
padding-right: 12px;
|
||||||
}
|
padding-left: 6px;
|
||||||
|
/* Hide scrollbar for clean look */
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
.watch-video-grid .video-grid-mobile {
|
.watch-secondary::-webkit-scrollbar {
|
||||||
display: flex !important;
|
display: none;
|
||||||
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 */
|
/* Player Wrapper */
|
||||||
|
|
@ -1605,52 +1395,3 @@ a {
|
||||||
transform: rotate(360deg);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
frontend/app/layout.tsx
Normal file → Executable file
|
|
@ -5,8 +5,6 @@ import './globals.css';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import MobileNav from './components/MobileNav';
|
import MobileNav from './components/MobileNav';
|
||||||
import HamburgerMenu from './components/HamburgerMenu';
|
|
||||||
import MainContent from './components/MainContent';
|
|
||||||
|
|
||||||
const roboto = Roboto({
|
const roboto = Roboto({
|
||||||
weight: ['400', '500', '700'],
|
weight: ['400', '500', '700'],
|
||||||
|
|
@ -16,33 +14,10 @@ const roboto = Roboto({
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'KV-Tube',
|
title: 'KV-Tube',
|
||||||
description: 'A modern YouTube-like video streaming platform with background playback',
|
description: 'A pixel perfect YouTube clone',
|
||||||
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 { ThemeProvider } from './context/ThemeContext';
|
||||||
import { SidebarProvider } from './context/SidebarContext';
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -64,29 +39,15 @@ export default function RootLayout({
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
navigator.serviceWorker.register('/sw.js');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<SidebarProvider>
|
|
||||||
<Header />
|
<Header />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<HamburgerMenu />
|
<main className="yt-main-content">
|
||||||
<MainContent>
|
|
||||||
{children}
|
{children}
|
||||||
</MainContent>
|
</main>
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
</SidebarProvider>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
48
frontend/app/page.tsx
Normal file → Executable file
|
|
@ -1,13 +1,10 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import InfiniteVideoGrid from './components/InfiniteVideoGrid';
|
import InfiniteVideoGrid from './components/InfiniteVideoGrid';
|
||||||
import VideoCard from './components/VideoCard';
|
|
||||||
import {
|
import {
|
||||||
getSearchVideos,
|
getSearchVideos,
|
||||||
getHistoryVideos,
|
getHistoryVideos,
|
||||||
getSuggestedVideos,
|
getSuggestedVideos
|
||||||
getRelatedVideos,
|
|
||||||
getRecentHistory
|
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import {
|
import {
|
||||||
VideoData,
|
VideoData,
|
||||||
|
|
@ -45,44 +42,8 @@ export default async function Home({
|
||||||
let gridVideos: VideoData[] = [];
|
let gridVideos: VideoData[] = [];
|
||||||
const randomMod = getRandomModifier();
|
const randomMod = getRandomModifier();
|
||||||
|
|
||||||
// Fetch recent history for mixing
|
|
||||||
let recentVideo: VideoData | null = null;
|
|
||||||
if (isAllCategory) {
|
if (isAllCategory) {
|
||||||
recentVideo = await getRecentHistory();
|
// Fetch top 6 from each category to build a robust recommendation feed
|
||||||
}
|
|
||||||
|
|
||||||
if (isAllCategory && recentVideo) {
|
|
||||||
// 40% Suggested, 40% Related, 20% Trending = 12:12:6 for 30 items
|
|
||||||
const promises = [
|
|
||||||
getSuggestedVideos(12),
|
|
||||||
getRelatedVideos(recentVideo.id, 12),
|
|
||||||
getSearchVideos(addRegion("trending", regionLabel) + ' ' + randomMod, 6)
|
|
||||||
];
|
|
||||||
|
|
||||||
const [suggestedRes, relatedRes, trendingRes] = await Promise.all(promises);
|
|
||||||
|
|
||||||
const interleavedList: VideoData[] = [];
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
|
|
||||||
let sIdx = 0, rIdx = 0, tIdx = 0;
|
|
||||||
while (sIdx < suggestedRes.length || rIdx < relatedRes.length || tIdx < trendingRes.length) {
|
|
||||||
for (let i = 0; i < 2 && sIdx < suggestedRes.length; i++) {
|
|
||||||
const v = suggestedRes[sIdx++];
|
|
||||||
if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); }
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 2 && rIdx < relatedRes.length; i++) {
|
|
||||||
const v = relatedRes[rIdx++];
|
|
||||||
if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); }
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 1 && tIdx < trendingRes.length; i++) {
|
|
||||||
const v = trendingRes[tIdx++];
|
|
||||||
if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gridVideos = interleavedList;
|
|
||||||
|
|
||||||
} else if (isAllCategory) {
|
|
||||||
// Fallback if no history
|
|
||||||
const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => {
|
const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => {
|
||||||
return await getSearchVideos(addRegion(sec.query, regionLabel) + ' ' + randomMod, 6);
|
return await getSearchVideos(addRegion(sec.query, regionLabel) + ' ' + randomMod, 6);
|
||||||
});
|
});
|
||||||
|
|
@ -116,11 +77,6 @@ export default async function Home({
|
||||||
gridVideos = await getSearchVideos(addRegion(searchQuery, regionLabel) + ' ' + randomMod, 30);
|
gridVideos = await getSearchVideos(addRegion(searchQuery, regionLabel) + ' ' + randomMod, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove duplicates from recent video
|
|
||||||
if (recentVideo) {
|
|
||||||
gridVideos = gridVideos.filter(video => video.id !== recentVideo!.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoriesList = Object.keys(CATEGORY_MAP);
|
const categoriesList = Object.keys(CATEGORY_MAP);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
0
frontend/app/search/page.tsx
Normal file → Executable file
0
frontend/app/shorts/page.tsx
Normal file → Executable file
0
frontend/app/utils.ts
Normal file → Executable file
|
|
@ -1,189 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { getVideoComments, CommentData } from '../actions';
|
|
||||||
|
|
||||||
interface CommentsProps {
|
|
||||||
videoId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Comments({ videoId }: CommentsProps) {
|
|
||||||
const [comments, setComments] = useState<CommentData[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(false);
|
|
||||||
setIsExpanded(false);
|
|
||||||
|
|
||||||
getVideoComments(videoId, 40)
|
|
||||||
.then(data => {
|
|
||||||
if (isMounted) {
|
|
||||||
const topLevel = data.filter(c => !c.is_reply);
|
|
||||||
setComments(topLevel);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
if (isMounted) {
|
|
||||||
console.error('Failed to load comments:', err);
|
|
||||||
setError(true);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [videoId]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="comments-section" style={{ marginTop: '24px', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
|
|
||||||
Comments are turned off or unavailable.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="comments-section" style={{ marginTop: '24px' }}>
|
|
||||||
<h3 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px' }}>Comments</h3>
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', gap: '16px', marginBottom: '20px' }}>
|
|
||||||
<div className="skeleton" style={{ width: '40px', height: '40px', borderRadius: '50%', flexShrink: 0 }} />
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div className="skeleton" style={{ height: '14px', width: '120px', marginBottom: '8px' }} />
|
|
||||||
<div className="skeleton" style={{ height: '14px', width: '80%', marginBottom: '4px' }} />
|
|
||||||
<div className="skeleton" style={{ height: '14px', width: '60%' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always render all comments; CSS handles mobile collapse via max-height
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="comments-section" style={{ marginTop: '24px' }}>
|
|
||||||
{/* Collapsed header for mobile - tappable to expand */}
|
|
||||||
{!isExpanded && comments.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="comments-collapsed-header"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'none', // Hidden on desktop, shown via CSS on mobile
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '12px 16px',
|
|
||||||
backgroundColor: 'var(--yt-hover)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
marginBottom: '16px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--yt-text-primary)' }}>
|
|
||||||
Comments
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', marginLeft: '8px' }}>
|
|
||||||
{comments.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
{comments[0] && (
|
|
||||||
<span style={{ fontSize: '13px', color: 'var(--yt-text-secondary)', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{comments[0].text.slice(0, 60)}...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--yt-text-secondary)"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Desktop: always show full title. Mobile: hidden when collapsed */}
|
|
||||||
<h3 className="comments-full-header" style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px', color: 'var(--yt-text-primary)' }}>
|
|
||||||
{comments.length} Comments
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className={`comments-list ${isExpanded ? 'expanded' : ''}`}>
|
|
||||||
{comments.map((c) => (
|
|
||||||
<div key={c.id} style={{ display: 'flex', gap: '16px', marginBottom: '20px' }}>
|
|
||||||
<div style={{ position: 'relative', width: '40px', height: '40px', borderRadius: '50%', overflow: 'hidden', flexShrink: 0, backgroundColor: 'var(--yt-hover)' }}>
|
|
||||||
<Image
|
|
||||||
src={c.author_thumbnail || 'https://i.ytimg.com/img/channels/c_ip_m_default.jpg'}
|
|
||||||
alt={c.author}
|
|
||||||
fill
|
|
||||||
sizes="40px"
|
|
||||||
style={{ objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, gap: '4px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
|
|
||||||
<span style={{ fontSize: '13px', fontWeight: 500, color: 'var(--yt-text-primary)' }}>
|
|
||||||
{c.author}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
|
|
||||||
{c.timestamp}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '20px',
|
|
||||||
color: 'var(--yt-text-primary)',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
overflowWrap: 'break-word',
|
|
||||||
wordBreak: 'break-word'
|
|
||||||
}}>
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: c.text }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{c.likes > 0 && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--yt-text-secondary)', fontSize: '12px' }}>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"></path></svg>
|
|
||||||
{c.likes}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show more / collapse toggle on mobile */}
|
|
||||||
{comments.length > 2 && (
|
|
||||||
<button
|
|
||||||
className="comments-toggle-btn"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
style={{
|
|
||||||
display: 'none', // Hidden on desktop, shown via CSS on mobile
|
|
||||||
width: '100%',
|
|
||||||
padding: '12px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: '1px solid var(--yt-border)',
|
|
||||||
borderRadius: '20px',
|
|
||||||
color: 'var(--yt-text-primary)',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginTop: '8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isExpanded ? 'Show less' : `Show ${comments.length - 2} more comments`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{comments.length === 0 && (
|
|
||||||
<div style={{ color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
|
|
||||||
No comments found.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,10 +2,13 @@
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export default function NextVideoClient({ videoId, listId }: { videoId: string, listId?: string }) {
|
export default function NextVideoClient({ videoId }: { videoId: string }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.dispatchEvent(new CustomEvent('setNextVideoId', { detail: { videoId, listId } }));
|
if (typeof window !== 'undefined' && videoId) {
|
||||||
}, [videoId, listId]);
|
const event = new CustomEvent('setNextVideoId', { detail: { videoId } });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}, [videoId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { VideoData } from '../constants';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
interface PlaylistPanelProps {
|
|
||||||
videos: VideoData[];
|
|
||||||
currentVideoId: string;
|
|
||||||
listId: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlaylistPanel({ videos, currentVideoId, listId, title }: PlaylistPanelProps) {
|
|
||||||
const currentIndex = videos.findIndex(v => v.id === currentVideoId);
|
|
||||||
const activeItemRef = useRef<HTMLAnchorElement>(null);
|
|
||||||
|
|
||||||
// Auto-scroll to active item on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeItemRef.current) {
|
|
||||||
activeItemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
|
||||||
}, [currentVideoId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'var(--yt-hover)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100%',
|
|
||||||
maxHeight: '500px',
|
|
||||||
marginBottom: '24px',
|
|
||||||
border: '1px solid var(--yt-border)'
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
|
||||||
padding: '16px',
|
|
||||||
borderBottom: '1px solid var(--yt-border)',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.2)'
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: 'var(--yt-text-primary)' }}>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<div style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', marginTop: '4px' }}>
|
|
||||||
{currentIndex + 1} / {videos.length} videos
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
<div style={{
|
|
||||||
overflowY: 'auto',
|
|
||||||
flex: 1,
|
|
||||||
padding: '8px 0'
|
|
||||||
}}>
|
|
||||||
{videos.map((video, index) => {
|
|
||||||
const isActive = video.id === currentVideoId;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={video.id}
|
|
||||||
href={`/watch?v=${video.id}&list=${listId}`}
|
|
||||||
ref={isActive ? activeItemRef : null}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '12px',
|
|
||||||
padding: '8px 16px',
|
|
||||||
textDecoration: 'none',
|
|
||||||
backgroundColor: isActive ? 'var(--yt-active)' : 'transparent',
|
|
||||||
alignItems: 'center',
|
|
||||||
transition: 'background-color 0.2s'
|
|
||||||
}}
|
|
||||||
className="playlist-item-hover"
|
|
||||||
>
|
|
||||||
{/* Number or Playing Icon */}
|
|
||||||
<div style={{
|
|
||||||
width: '24px',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: 'var(--yt-text-secondary)',
|
|
||||||
textAlign: 'center',
|
|
||||||
flexShrink: 0
|
|
||||||
}}>
|
|
||||||
{isActive ? '▶' : index + 1}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div style={{
|
|
||||||
position: 'relative',
|
|
||||||
width: '100px',
|
|
||||||
aspectRatio: '16/9',
|
|
||||||
flexShrink: 0,
|
|
||||||
borderRadius: '8px',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
<Image
|
|
||||||
src={video.thumbnail}
|
|
||||||
alt={video.title}
|
|
||||||
fill
|
|
||||||
sizes="100px"
|
|
||||||
style={{ objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
{video.duration && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '4px',
|
|
||||||
right: '4px',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '2px 4px',
|
|
||||||
fontSize: '10px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontWeight: 500
|
|
||||||
}}>
|
|
||||||
{video.duration}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, justifyContent: 'center' }}>
|
|
||||||
<h4 style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: isActive ? 600 : 400,
|
|
||||||
color: 'var(--yt-text-primary)',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
{video.title}
|
|
||||||
</h4>
|
|
||||||
<div style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', marginTop: '4px' }}>
|
|
||||||
{video.uploader}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
373
frontend/app/watch/VideoPlayer.tsx
Normal file → Executable file
|
|
@ -34,6 +34,20 @@ interface StreamInfo {
|
||||||
function PlayerSkeleton() {
|
function PlayerSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div style={skeletonContainerStyle}>
|
<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={skeletonCenterStyle}>
|
||||||
<div style={skeletonSpinnerStyle} />
|
<div style={skeletonSpinnerStyle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,23 +71,12 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isBuffering, setIsBuffering] = useState(false);
|
const [isBuffering, setIsBuffering] = useState(false);
|
||||||
const [nextVideoId, setNextVideoId] = useState<string | undefined>();
|
const [nextVideoId, setNextVideoId] = useState<string | undefined>();
|
||||||
const [nextListId, setNextListId] = useState<string | undefined>();
|
|
||||||
const [showBackgroundHint, setShowBackgroundHint] = useState(false);
|
|
||||||
const [wakeLock, setWakeLock] = useState<any>(null);
|
|
||||||
const [isPiPActive, setIsPiPActive] = useState(false);
|
|
||||||
const [autoPiPEnabled, setAutoPiPEnabled] = useState(true);
|
|
||||||
const [showPiPNotification, setShowPiPNotification] = useState(false);
|
|
||||||
const audioUrlRef = useRef<string>('');
|
const audioUrlRef = useRef<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSetNextVideo = (e: CustomEvent) => {
|
const handleSetNextVideo = (e: CustomEvent) => {
|
||||||
if (e.detail && e.detail.videoId) {
|
if (e.detail && e.detail.videoId) {
|
||||||
setNextVideoId(e.detail.videoId);
|
setNextVideoId(e.detail.videoId);
|
||||||
if (e.detail.listId !== undefined) {
|
|
||||||
setNextListId(e.detail.listId);
|
|
||||||
} else {
|
|
||||||
setNextListId(undefined);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('setNextVideoId', handleSetNextVideo as EventListener);
|
window.addEventListener('setNextVideoId', handleSetNextVideo as EventListener);
|
||||||
|
|
@ -94,159 +97,20 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!video || !audio || !hasSeparateAudio) return;
|
if (!video || !audio || !hasSeparateAudio) return;
|
||||||
|
|
||||||
const isHidden = document.visibilityState === 'hidden';
|
if (Math.abs(video.currentTime - audio.currentTime) > 0.2) {
|
||||||
|
|
||||||
if (Math.abs(video.currentTime - audio.currentTime) > 0.4) {
|
|
||||||
if (isHidden) {
|
|
||||||
// When hidden, video might be suspended by the browser.
|
|
||||||
// Don't pull audio back to the frozen video. Let audio play.
|
|
||||||
// However, if audio is somehow pausing/lagging, we don't force it here.
|
|
||||||
} else {
|
|
||||||
// When visible, normally video is the master timeline.
|
|
||||||
// BUT, if we just came back from background, audio might be way ahead.
|
|
||||||
// Instead of always rewinding audio, if video is lagging behind audio (like recovering from sleep),
|
|
||||||
// we should jump the video forward to catch up to the audio!
|
|
||||||
if (audio.currentTime > video.currentTime + 1) {
|
|
||||||
// Video is lagging, jump it forward
|
|
||||||
video.currentTime = audio.currentTime;
|
|
||||||
} else {
|
|
||||||
// Audio is lagging or drifting slightly, snap audio to video
|
|
||||||
audio.currentTime = video.currentTime;
|
audio.currentTime = video.currentTime;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (video.paused && !audio.paused) {
|
if (video.paused && !audio.paused) {
|
||||||
if (!isHidden) {
|
|
||||||
audio.pause();
|
audio.pause();
|
||||||
}
|
|
||||||
} else if (!video.paused && audio.paused) {
|
} else if (!video.paused && audio.paused) {
|
||||||
// Only force audio to play if it got stuck
|
|
||||||
audio.play().catch(() => { });
|
audio.play().catch(() => { });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVisibilityChange = async () => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
// Page is hidden - automatically enter Picture-in-Picture if enabled
|
|
||||||
if (!video.paused && autoPiPEnabled) {
|
|
||||||
try {
|
|
||||||
if (document.pictureInPictureEnabled && !document.pictureInPictureElement) {
|
|
||||||
await video.requestPictureInPicture();
|
|
||||||
setIsPiPActive(true);
|
|
||||||
setShowPiPNotification(true);
|
|
||||||
setTimeout(() => setShowPiPNotification(false), 3000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Auto PiP failed, using audio fallback:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Release wake lock when page is hidden (PiP handles its own wake lock)
|
|
||||||
releaseWakeLock();
|
|
||||||
} else {
|
|
||||||
// Page is visible again
|
|
||||||
if (autoPiPEnabled) {
|
|
||||||
try {
|
|
||||||
if (document.pictureInPictureElement) {
|
|
||||||
await document.exitPictureInPicture();
|
|
||||||
setIsPiPActive(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Exit PiP failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-acquire wake lock when page is visible
|
|
||||||
if (!video.paused) requestWakeLock();
|
|
||||||
|
|
||||||
// Recover video position from audio if audio continued playing in background
|
|
||||||
if (hasSeparateAudio && audio && !audio.paused) {
|
|
||||||
if (audio.currentTime > video.currentTime + 1) {
|
|
||||||
video.currentTime = audio.currentTime;
|
|
||||||
}
|
|
||||||
if (video.paused) {
|
|
||||||
video.play().catch(() => { });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wake Lock API to prevent screen from sleeping during playback
|
|
||||||
const requestWakeLock = async () => {
|
|
||||||
try {
|
|
||||||
if ('wakeLock' in navigator) {
|
|
||||||
const wakeLockSentinel = await (navigator as any).wakeLock.request('screen');
|
|
||||||
setWakeLock(wakeLockSentinel);
|
|
||||||
|
|
||||||
wakeLockSentinel.addEventListener('release', () => {
|
|
||||||
setWakeLock(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Wake Lock not supported or failed:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const releaseWakeLock = async () => {
|
|
||||||
if (wakeLock) {
|
|
||||||
try {
|
|
||||||
await wakeLock.release();
|
|
||||||
setWakeLock(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Failed to release wake lock:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Picture-in-Picture support
|
|
||||||
const togglePiP = async () => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (document.pictureInPictureElement) {
|
|
||||||
await document.exitPictureInPicture();
|
|
||||||
setIsPiPActive(false);
|
|
||||||
} else if (document.pictureInPictureEnabled) {
|
|
||||||
await video.requestPictureInPicture();
|
|
||||||
setIsPiPActive(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Picture-in-Picture not supported or failed:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for PiP events
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
const handleEnterPiP = () => setIsPiPActive(true);
|
|
||||||
const handleLeavePiP = () => setIsPiPActive(false);
|
|
||||||
|
|
||||||
video.addEventListener('enterpictureinpicture', handleEnterPiP);
|
|
||||||
video.addEventListener('leavepictureinpicture', handleLeavePiP);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
video.removeEventListener('enterpictureinpicture', handleEnterPiP);
|
|
||||||
video.removeEventListener('leavepictureinpicture', handleLeavePiP);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (useFallback) return;
|
if (useFallback) return;
|
||||||
|
|
||||||
// Reset states when videoId changes
|
|
||||||
setIsLoading(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
setError(null);
|
|
||||||
setQualities([]);
|
|
||||||
setShowQualityMenu(false);
|
|
||||||
|
|
||||||
const loadStream = async () => {
|
const loadStream = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/get_stream_info?v=${videoId}`);
|
const res = await fetch(`/api/get_stream_info?v=${videoId}`);
|
||||||
|
|
@ -269,36 +133,21 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Stream load error:', err);
|
console.error('Stream load error:', err);
|
||||||
// Only show error after multiple retries, not immediately
|
|
||||||
setError('Failed to load stream');
|
setError('Failed to load stream');
|
||||||
setUseFallback(true);
|
setUseFallback(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const tryLoad = (retries = 0) => {
|
const tryLoad = () => {
|
||||||
if (window.Hls) {
|
if (window.Hls) {
|
||||||
loadStream();
|
loadStream();
|
||||||
} else if (retries < 50) { // Wait up to 5 seconds for HLS to load
|
|
||||||
setTimeout(() => tryLoad(retries + 1), 100);
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to native video player if HLS fails to load
|
setTimeout(tryLoad, 100);
|
||||||
loadStream();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tryLoad();
|
tryLoad();
|
||||||
|
|
||||||
// Record history once per videoId
|
|
||||||
fetch('/api/history', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
video_id: videoId,
|
|
||||||
title: title,
|
|
||||||
thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
|
|
||||||
}),
|
|
||||||
}).catch(err => console.error('Failed to record history', err));
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (hlsRef.current) {
|
if (hlsRef.current) {
|
||||||
hlsRef.current.destroy();
|
hlsRef.current.destroy();
|
||||||
|
|
@ -312,6 +161,8 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
}, [videoId]);
|
}, [videoId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!hasSeparateAudio) return;
|
||||||
|
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
|
|
@ -326,13 +177,10 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
video.addEventListener(event, handler);
|
video.addEventListener(event, handler);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
Object.entries(handlers).forEach(([event, handler]) => {
|
Object.entries(handlers).forEach(([event, handler]) => {
|
||||||
video.removeEventListener(event, handler);
|
video.removeEventListener(event, handler);
|
||||||
});
|
});
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
};
|
};
|
||||||
}, [hasSeparateAudio]);
|
}, [hasSeparateAudio]);
|
||||||
|
|
||||||
|
|
@ -350,55 +198,15 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
const handleWaiting = () => setIsBuffering(true);
|
const handleWaiting = () => setIsBuffering(true);
|
||||||
const handleLoadStart = () => setIsLoading(true);
|
const handleLoadStart = () => setIsLoading(true);
|
||||||
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
|
||||||
title: title || 'KV-Tube Video',
|
|
||||||
artist: 'KV-Tube',
|
|
||||||
artwork: [
|
|
||||||
{ src: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, sizes: '480x360', type: 'image/jpeg' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
navigator.mediaSession.setActionHandler('play', () => {
|
|
||||||
video.play().catch(() => { });
|
|
||||||
if (needsSeparateAudio && audioRef.current) audioRef.current.play().catch(() => { });
|
|
||||||
});
|
|
||||||
navigator.mediaSession.setActionHandler('pause', () => {
|
|
||||||
video.pause();
|
|
||||||
if (needsSeparateAudio && audioRef.current) audioRef.current.pause();
|
|
||||||
});
|
|
||||||
// Add seek handlers for better background control
|
|
||||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
|
||||||
if (details.seekTime !== undefined) {
|
|
||||||
video.currentTime = details.seekTime;
|
|
||||||
if (needsSeparateAudio && audioRef.current) {
|
|
||||||
audioRef.current.currentTime = details.seekTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
video.addEventListener('canplay', handleCanPlay);
|
video.addEventListener('canplay', handleCanPlay);
|
||||||
video.addEventListener('playing', handlePlaying);
|
video.addEventListener('playing', handlePlaying);
|
||||||
video.addEventListener('waiting', handleWaiting);
|
video.addEventListener('waiting', handleWaiting);
|
||||||
video.addEventListener('loadstart', handleLoadStart);
|
video.addEventListener('loadstart', handleLoadStart);
|
||||||
|
|
||||||
// Wake lock event listeners
|
|
||||||
const handlePlay = () => requestWakeLock();
|
|
||||||
const handlePause = () => releaseWakeLock();
|
|
||||||
const handleEnded = () => releaseWakeLock();
|
|
||||||
|
|
||||||
video.addEventListener('play', handlePlay);
|
|
||||||
video.addEventListener('pause', handlePause);
|
|
||||||
video.addEventListener('ended', handleEnded);
|
|
||||||
|
|
||||||
if (isHLS && window.Hls && window.Hls.isSupported()) {
|
if (isHLS && window.Hls && window.Hls.isSupported()) {
|
||||||
if (hlsRef.current) hlsRef.current.destroy();
|
if (hlsRef.current) hlsRef.current.destroy();
|
||||||
|
|
||||||
// Enhance buffer to mitigate Safari slow loading and choppiness
|
|
||||||
const hls = new window.Hls({
|
const hls = new window.Hls({
|
||||||
maxBufferLength: 60,
|
|
||||||
maxMaxBufferLength: 120,
|
|
||||||
enableWorker: true,
|
|
||||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||||
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
|
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
|
||||||
},
|
},
|
||||||
|
|
@ -414,20 +222,12 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
|
|
||||||
hls.on(window.Hls.Events.ERROR, (_: any, data: any) => {
|
hls.on(window.Hls.Events.ERROR, (_: any, data: any) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
// Try to recover from error first
|
|
||||||
if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
|
|
||||||
hls.recoverMediaError();
|
|
||||||
} else if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
|
|
||||||
// Try to reload the source
|
|
||||||
hls.loadSource(streamUrl);
|
|
||||||
} else {
|
|
||||||
// Only fall back for other fatal errors
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setError('Playback error');
|
||||||
setUseFallback(true);
|
setUseFallback(true);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else if (isHLS && video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
video.src = streamUrl;
|
video.src = streamUrl;
|
||||||
video.onloadedmetadata = () => video.play().catch(() => { });
|
video.onloadedmetadata = () => video.play().catch(() => { });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -444,9 +244,6 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
if (audioHlsRef.current) audioHlsRef.current.destroy();
|
if (audioHlsRef.current) audioHlsRef.current.destroy();
|
||||||
|
|
||||||
const audioHls = new window.Hls({
|
const audioHls = new window.Hls({
|
||||||
maxBufferLength: 60,
|
|
||||||
maxMaxBufferLength: 120,
|
|
||||||
enableWorker: true,
|
|
||||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||||
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
|
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
|
||||||
},
|
},
|
||||||
|
|
@ -455,8 +252,6 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
|
|
||||||
audioHls.loadSource(audioStreamUrl!);
|
audioHls.loadSource(audioStreamUrl!);
|
||||||
audioHls.attachMedia(audio);
|
audioHls.attachMedia(audio);
|
||||||
} else if (audioIsHLS && audio.canPlayType('application/vnd.apple.mpegurl')) {
|
|
||||||
audio.src = audioStreamUrl!;
|
|
||||||
} else {
|
} else {
|
||||||
audio.src = audioStreamUrl!;
|
audio.src = audioStreamUrl!;
|
||||||
}
|
}
|
||||||
|
|
@ -465,32 +260,7 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
|
|
||||||
video.onended = () => {
|
video.onended = () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
if (nextVideoId) {
|
if (nextVideoId) router.push(`/watch?v=${nextVideoId}`);
|
||||||
const url = nextListId ? `/watch?v=${nextVideoId}&list=${nextListId}` : `/watch?v=${nextVideoId}`;
|
|
||||||
router.push(url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle video being paused by browser (e.g., when tab is hidden)
|
|
||||||
const handlePauseForBackground = () => {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio || !hasSeparateAudio) return;
|
|
||||||
|
|
||||||
// If the tab is hidden and video was paused, it was likely paused by Chrome saving resources.
|
|
||||||
// Keep the audio playing!
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
audio.play().catch(() => { });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener('pause', handlePauseForBackground);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
video.removeEventListener('pause', handlePauseForBackground);
|
|
||||||
video.removeEventListener('play', handlePlay);
|
|
||||||
video.removeEventListener('pause', handlePause);
|
|
||||||
video.removeEventListener('ended', handleEnded);
|
|
||||||
releaseWakeLock();
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -535,9 +305,9 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => setShowControls(false)}>
|
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => setShowControls(false)}>
|
||||||
<iframe
|
<iframe
|
||||||
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1&playsinline=1`}
|
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1`}
|
||||||
style={iframeStyle}
|
style={iframeStyle}
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; background-sync"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
title={title || 'Video'}
|
title={title || 'Video'}
|
||||||
/>
|
/>
|
||||||
|
|
@ -547,44 +317,22 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
|
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
|
||||||
{/* Show skeleton only during initial load, buffering overlay during playback */}
|
|
||||||
{isLoading && <PlayerSkeleton />}
|
{isLoading && <PlayerSkeleton />}
|
||||||
|
|
||||||
{/* Show buffering overlay only when not in initial load state */}
|
|
||||||
{isBuffering && !isLoading && (
|
|
||||||
<div style={bufferingOverlayStyle}>
|
|
||||||
<div style={spinnerStyle} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
|
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
webkit-playsinline="true"
|
|
||||||
x5-playsinline="true"
|
|
||||||
x5-video-player-type="h5"
|
|
||||||
x5-video-player-fullscreen="true"
|
|
||||||
preload="auto"
|
|
||||||
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<audio ref={audioRef} style={{ display: 'none' }} />
|
<audio ref={audioRef} style={{ display: 'none' }} />
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={errorContainerStyle}>
|
|
||||||
<div style={errorStyle}>
|
<div style={errorStyle}>
|
||||||
<span style={{ marginBottom: '8px' }}>{error}</span>
|
<span>{error}</span>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<button onClick={() => setUseFallback(true)} style={retryBtnStyle}>Try YouTube Player</button>
|
||||||
<button onClick={() => { setError(null); setUseFallback(false); window.location.reload(); }} style={retryBtnStyle}>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setUseFallback(true)} style={{ ...retryBtnStyle, background: '#333' }}>
|
|
||||||
Use YouTube Player
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -594,27 +342,6 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
Open on YouTube ↗
|
Open on YouTube ↗
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div style={controlsRowStyle}>
|
|
||||||
{/* Picture-in-Picture Button */}
|
|
||||||
{document.pictureInPictureEnabled && (
|
|
||||||
<button
|
|
||||||
onClick={togglePiP}
|
|
||||||
style={pipBtnStyle}
|
|
||||||
title={isPiPActive ? "Exit Picture-in-Picture" : "Picture-in-Picture"}
|
|
||||||
>
|
|
||||||
{isPiPActive ? '⏹' : '📺'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Auto PiP Toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => setAutoPiPEnabled(!autoPiPEnabled)}
|
|
||||||
style={{ ...pipBtnStyle, background: autoPiPEnabled ? 'rgba(255,0,0,0.8)' : 'rgba(0,0,0,0.8)' }}
|
|
||||||
title={autoPiPEnabled ? "Auto PiP: ON (click to disable)" : "Auto PiP: OFF (click to enable)"}
|
|
||||||
>
|
|
||||||
{autoPiPEnabled ? '🔄' : '⏸'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{qualities.length > 0 && (
|
{qualities.length > 0 && (
|
||||||
<div style={qualityContainerStyle}>
|
<div style={qualityContainerStyle}>
|
||||||
<button onClick={() => setShowQualityMenu(!showQualityMenu)} style={qualityBtnStyle}>
|
<button onClick={() => setShowQualityMenu(!showQualityMenu)} style={qualityBtnStyle}>
|
||||||
|
|
@ -643,31 +370,9 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showBackgroundHint && (
|
{isBuffering && !isLoading && (
|
||||||
<div style={backgroundHintStyle}>
|
<div style={bufferingOverlayStyle}>
|
||||||
{hasSeparateAudio ? (
|
<div style={spinnerStyle} />
|
||||||
<span>🎵 Audio playing in background</span>
|
|
||||||
) : (
|
|
||||||
<span>⚠️ Background playback may pause on some browsers</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setUseFallback(true)}
|
|
||||||
style={backgroundHintBtnStyle}
|
|
||||||
>
|
|
||||||
Use YouTube Player for better background playback
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showPiPNotification && (
|
|
||||||
<div style={pipNotificationStyle}>
|
|
||||||
<span>📺 Picture-in-Picture activated</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setAutoPiPEnabled(!autoPiPEnabled)}
|
|
||||||
style={pipToggleBtnStyle}
|
|
||||||
>
|
|
||||||
{autoPiPEnabled ? 'Disable Auto PiP' : 'Enable Auto PiP'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -678,8 +383,7 @@ const noVideoStyle: React.CSSProperties = { width: '100%', background: '#000', b
|
||||||
const containerStyle: React.CSSProperties = { width: '100%', background: '#000', borderRadius: '12px', overflow: 'hidden', aspectRatio: '16/9', position: 'relative' };
|
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 videoStyle: React.CSSProperties = { width: '100%', height: '100%', background: '#000' };
|
||||||
const iframeStyle: React.CSSProperties = { width: '100%', height: '100%', border: 'none' };
|
const iframeStyle: React.CSSProperties = { width: '100%', height: '100%', border: 'none' };
|
||||||
const errorContainerStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.8)' };
|
const errorStyle: React.CSSProperties = { 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 errorStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px', padding: '20px', background: 'rgba(30,30,30,0.95)', borderRadius: '12px', color: '#fff', maxWidth: '90%' };
|
|
||||||
const retryBtnStyle: React.CSSProperties = { padding: '8px 16px', background: '#ff0000', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px' };
|
const 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 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 qualityContainerStyle: React.CSSProperties = { position: 'absolute', bottom: '50px', right: '10px', zIndex: 10 };
|
||||||
|
|
@ -687,14 +391,13 @@ const qualityBtnStyle: React.CSSProperties = { padding: '6px 12px', background:
|
||||||
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 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 qualityItemStyle: React.CSSProperties = { display: 'block', width: '100%', padding: '8px 16px', color: '#fff', border: 'none', background: 'transparent', textAlign: 'left', cursor: 'pointer', fontSize: '13px', whiteSpace: 'nowrap' };
|
||||||
|
|
||||||
const skeletonContainerStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', zIndex: 5 };
|
const skeletonContainerStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', background: '#000', zIndex: 5 };
|
||||||
const skeletonCenterStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'center' };
|
const skeletonVideoStyle: React.CSSProperties = { flex: 1, margin: '8px', borderRadius: '8px' };
|
||||||
const skeletonSpinnerStyle: React.CSSProperties = { width: '48px', height: '48px', border: '4px solid rgba(255,255,255,0.2)', borderTopColor: '#fff', borderRadius: '50%', animation: 'spin 1s linear infinite' };
|
const 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 bufferingOverlayStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', pointerEvents: 'none', zIndex: 5 };
|
||||||
const spinnerStyle: React.CSSProperties = { width: '40px', height: '40px', border: '3px solid rgba(255,255,255,0.2)', borderTopColor: '#fff', borderRadius: '50%', animation: 'spin 0.8s linear infinite' };
|
const spinnerStyle: React.CSSProperties = { width: '40px', height: '40px', border: '3px solid rgba(255,255,255,0.2)', borderTopColor: '#fff', borderRadius: '50%', animation: 'spin 0.8s linear infinite' };
|
||||||
const backgroundHintStyle: React.CSSProperties = { position: 'absolute', bottom: '80px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(0,0,0,0.9)', color: '#fff', padding: '12px 16px', borderRadius: '8px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center', zIndex: 20, fontSize: '14px' };
|
|
||||||
const backgroundHintBtnStyle: React.CSSProperties = { padding: '6px 12px', background: '#ff0000', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' };
|
|
||||||
const controlsRowStyle: React.CSSProperties = { position: 'absolute', bottom: '10px', left: '10px', display: 'flex', gap: '8px', zIndex: 10 };
|
|
||||||
const pipBtnStyle: React.CSSProperties = { padding: '6px 10px', background: 'rgba(0,0,0,0.8)', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px' };
|
|
||||||
const pipNotificationStyle: React.CSSProperties = { position: 'absolute', top: '10px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(0,0,0,0.9)', color: '#fff', padding: '10px 16px', borderRadius: '8px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center', zIndex: 20, fontSize: '13px', animation: 'fadeIn 0.3s ease-out' };
|
|
||||||
const pipToggleBtnStyle: React.CSSProperties = { padding: '4px 8px', background: '#333', color: '#fff', border: '1px solid #555', borderRadius: '4px', cursor: 'pointer', fontSize: '11px' };
|
|
||||||
|
|
|
||||||
166
frontend/app/watch/WatchActions.tsx
Normal file → Executable file
|
|
@ -236,32 +236,95 @@ export default function WatchActions({ videoId }: { videoId: string }) {
|
||||||
setDownloadProgress('Preparing download...');
|
setDownloadProgress('Preparing download...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simple approach: use the backend's download-file endpoint
|
const needsAudioMerge = format && !format.has_audio && format.type !== 'both';
|
||||||
const downloadUrl = `/api/download-file?v=${encodeURIComponent(videoId)}${format ? `&f=${encodeURIComponent(format.format_id)}` : ''}`;
|
|
||||||
|
|
||||||
// Create a temporary anchor tag to trigger download
|
if (!needsAudioMerge) {
|
||||||
const a = document.createElement('a');
|
setDownloadProgress('Getting download link...');
|
||||||
a.href = downloadUrl;
|
const res = await fetch(`/api/download?v=${encodeURIComponent(videoId)}${format ? `&f=${encodeURIComponent(format.format_id)}` : ''}`);
|
||||||
a.download = ''; // Let the server set filename via Content-Disposition
|
const data = await res.json();
|
||||||
document.body.appendChild(a);
|
if (data.url) {
|
||||||
a.click();
|
window.open(data.url, '_blank');
|
||||||
document.body.removeChild(a);
|
setIsDownloading(false);
|
||||||
|
setDownloadProgress('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setDownloadProgress('Download started!');
|
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);
|
setProgressPercent(100);
|
||||||
|
} catch (e: any) {
|
||||||
// Reset after a short delay
|
console.error(e);
|
||||||
|
alert(e.message || 'Download failed. Please try again.');
|
||||||
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setDownloadProgress('');
|
setDownloadProgress('');
|
||||||
setProgressPercent(0);
|
setProgressPercent(0);
|
||||||
}, 2000);
|
}, 1500);
|
||||||
} catch (e: any) {
|
|
||||||
console.error(e);
|
|
||||||
alert(e.message || 'Download failed. Please try again.');
|
|
||||||
setIsDownloading(false);
|
|
||||||
setDownloadProgress('');
|
|
||||||
setProgressPercent(0);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -285,21 +348,6 @@ export default function WatchActions({ videoId }: { videoId: string }) {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||||
{showFormats && (
|
|
||||||
<div
|
|
||||||
className="download-backdrop"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
||||||
zIndex: 9999
|
|
||||||
}}
|
|
||||||
onClick={() => setShowFormats(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={fetchFormats}
|
onClick={fetchFormats}
|
||||||
|
|
@ -317,48 +365,22 @@ export default function WatchActions({ videoId }: { videoId: string }) {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showFormats && (
|
{showFormats && (
|
||||||
<div
|
<div style={{
|
||||||
className="download-dropdown"
|
position: 'absolute',
|
||||||
style={{
|
top: '42px',
|
||||||
position: 'fixed',
|
right: 0,
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
backgroundColor: 'var(--yt-background)',
|
backgroundColor: 'var(--yt-background)',
|
||||||
borderRadius: '16px',
|
borderRadius: '12px',
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
boxShadow: 'var(--yt-shadow-lg)',
|
||||||
padding: '0',
|
padding: '8px 0',
|
||||||
zIndex: 10000,
|
zIndex: 1000,
|
||||||
width: 'calc(100% - 32px)',
|
minWidth: '240px',
|
||||||
maxWidth: '360px',
|
maxHeight: '360px',
|
||||||
maxHeight: '70vh',
|
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
border: '1px solid var(--yt-border)',
|
border: '1px solid var(--yt-border)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{ padding: '10px 16px', fontSize: '13px', fontWeight: '600', color: 'var(--yt-text-primary)', borderBottom: '1px solid var(--yt-border)' }}>
|
||||||
padding: '16px',
|
Select Quality
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--yt-text-primary)',
|
|
||||||
borderBottom: '1px solid var(--yt-border)',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
|
||||||
<span>Select Quality</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFormats(false)}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'var(--yt-text-secondary)',
|
|
||||||
fontSize: '20px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px 8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoadingFormats ? (
|
{isLoadingFormats ? (
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import InfiniteVideoGrid from '../components/InfiniteVideoGrid';
|
|
||||||
import { VideoData } from '../constants';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
videoId: string;
|
|
||||||
regionLabel: string;
|
|
||||||
initialMix: VideoData[]; // Initial 40:40:20 mix data for "All" tab
|
|
||||||
initialRelated: VideoData[]; // Initial related data for "Related" tab
|
|
||||||
initialSuggestions: VideoData[]; // Initial suggestions data for "For You" tab
|
|
||||||
}
|
|
||||||
|
|
||||||
const WATCH_TABS = ['All', 'Mix', 'Related', 'For You', 'Trending'];
|
|
||||||
|
|
||||||
export default function WatchFeed({ videoId, regionLabel, initialMix, initialRelated, initialSuggestions }: Props) {
|
|
||||||
const [activeTab, setActiveTab] = useState('All');
|
|
||||||
|
|
||||||
// Determine category id and initial videos based on active tab
|
|
||||||
let currentCategory = 'WatchAll';
|
|
||||||
let videos = initialMix;
|
|
||||||
|
|
||||||
if (activeTab === 'Related') {
|
|
||||||
currentCategory = 'WatchRelated';
|
|
||||||
videos = initialRelated;
|
|
||||||
} else if (activeTab === 'For You') {
|
|
||||||
currentCategory = 'WatchForYou';
|
|
||||||
videos = initialSuggestions;
|
|
||||||
} else if (activeTab === 'Trending') {
|
|
||||||
currentCategory = 'Trending';
|
|
||||||
// 'Trending' falls back to standard fetchMoreVideos logic which handles normal categories or we can handle it specifically.
|
|
||||||
// It's empty initially if missing, the infinite grid will load it.
|
|
||||||
videos = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: '24px' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '12px', padding: '0 0 16px 0', overflowX: 'auto' }} className="hide-scrollbox">
|
|
||||||
{WATCH_TABS.map((tab) => {
|
|
||||||
const isActive = tab === activeTab;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => {
|
|
||||||
if (tab === 'Mix') {
|
|
||||||
window.location.href = `/watch?v=${videoId}&list=RD${videoId}`;
|
|
||||||
} else {
|
|
||||||
setActiveTab(tab);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`chip ${isActive ? 'active' : ''}`}
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
backgroundColor: isActive ? 'var(--foreground)' : 'var(--yt-hover)',
|
|
||||||
color: isActive ? 'var(--background)' : 'var(--yt-text-primary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="watch-video-grid">
|
|
||||||
<InfiniteVideoGrid
|
|
||||||
key={currentCategory} // Force unmount/remount on tab change
|
|
||||||
initialVideos={videos}
|
|
||||||
currentCategory={currentCategory}
|
|
||||||
regionLabel={regionLabel}
|
|
||||||
contextVideoId={videoId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
171
frontend/app/watch/page.tsx
Normal file → Executable file
|
|
@ -3,24 +3,8 @@ import VideoPlayer from './VideoPlayer';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import WatchActions from './WatchActions';
|
import WatchActions from './WatchActions';
|
||||||
import SubscribeButton from '../components/SubscribeButton';
|
import SubscribeButton from '../components/SubscribeButton';
|
||||||
import NextVideoClient from './NextVideoClient';
|
import RelatedVideos from './RelatedVideos';
|
||||||
import WatchFeed from './WatchFeed';
|
import { API_BASE } from '../constants';
|
||||||
import PlaylistPanel from './PlaylistPanel';
|
|
||||||
import Comments from './Comments';
|
|
||||||
import { API_BASE, VideoData } from '../constants';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
import { getRelatedVideos, getSuggestedVideos, getSearchVideos } from '../actions';
|
|
||||||
import { addRegion, getRandomModifier } from '../utils';
|
|
||||||
|
|
||||||
const REGION_LABELS: Record<string, string> = {
|
|
||||||
VN: 'Vietnam',
|
|
||||||
US: 'United States',
|
|
||||||
JP: 'Japan',
|
|
||||||
KR: 'South Korea',
|
|
||||||
IN: 'India',
|
|
||||||
GB: 'United Kingdom',
|
|
||||||
GLOBAL: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface VideoInfo {
|
interface VideoInfo {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -63,8 +47,6 @@ export default async function WatchPage({
|
||||||
}) {
|
}) {
|
||||||
const awaitParams = await searchParams;
|
const awaitParams = await searchParams;
|
||||||
const v = awaitParams.v as string;
|
const v = awaitParams.v as string;
|
||||||
const list = awaitParams.list as string;
|
|
||||||
const isMix = list?.startsWith('RD');
|
|
||||||
|
|
||||||
if (!v) {
|
if (!v) {
|
||||||
return <div style={{ padding: '2rem' }}>No video ID provided</div>;
|
return <div style={{ padding: '2rem' }}>No video ID provided</div>;
|
||||||
|
|
@ -72,134 +54,8 @@ export default async function WatchPage({
|
||||||
|
|
||||||
const info = await getVideoInfo(v);
|
const info = await getVideoInfo(v);
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const regionCode = cookieStore.get('region')?.value || 'VN';
|
|
||||||
const regionLabel = REGION_LABELS[regionCode] || '';
|
|
||||||
const randomMod = getRandomModifier();
|
|
||||||
|
|
||||||
// Fetch initial mix
|
|
||||||
const promises = [
|
|
||||||
getSuggestedVideos(12),
|
|
||||||
getRelatedVideos(v, 12),
|
|
||||||
getSearchVideos(addRegion("trending", regionLabel) + ' ' + randomMod, 6)
|
|
||||||
];
|
|
||||||
|
|
||||||
const [suggestedRes, relatedRes, trendingRes] = await Promise.all(promises);
|
|
||||||
|
|
||||||
const interleavedList: VideoData[] = [];
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
|
|
||||||
let sIdx = 0, rIdx = 0, tIdx = 0;
|
|
||||||
while (sIdx < suggestedRes.length || rIdx < relatedRes.length || tIdx < trendingRes.length) {
|
|
||||||
for (let i = 0; i < 2 && sIdx < suggestedRes.length; i++) {
|
|
||||||
const vid = suggestedRes[sIdx++];
|
|
||||||
if (!seenIds.has(vid.id) && vid.id !== v) { interleavedList.push(vid); seenIds.add(vid.id); }
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 2 && rIdx < relatedRes.length; i++) {
|
|
||||||
const vid = relatedRes[rIdx++];
|
|
||||||
if (!seenIds.has(vid.id) && vid.id !== v) { interleavedList.push(vid); seenIds.add(vid.id); }
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 1 && tIdx < trendingRes.length; i++) {
|
|
||||||
const vid = trendingRes[tIdx++];
|
|
||||||
if (!seenIds.has(vid.id) && vid.id !== v) { interleavedList.push(vid); seenIds.add(vid.id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let initialMix = interleavedList;
|
|
||||||
const initialRelated = relatedRes.filter(vid => vid.id !== v);
|
|
||||||
const initialSuggestions = suggestedRes.filter(vid => vid.id !== v);
|
|
||||||
|
|
||||||
// If not currently inside a mix, inject a Mix Card at the start
|
|
||||||
if (!isMix && info) {
|
|
||||||
const mixCard: VideoData = {
|
|
||||||
id: v,
|
|
||||||
title: `Mix - ${info.uploader || 'Auto-generated'}`,
|
|
||||||
uploader: info.uploader || 'KV-Tube',
|
|
||||||
thumbnail: info.thumbnail || `https://i.ytimg.com/vi/${v}/hqdefault.jpg`,
|
|
||||||
view_count: 0,
|
|
||||||
duration: '50+',
|
|
||||||
list_id: `RD${v}`,
|
|
||||||
is_mix: true
|
|
||||||
};
|
|
||||||
initialMix.unshift(mixCard);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always build a Mix playlist
|
|
||||||
let playlistVideos: VideoData[] = [];
|
|
||||||
const mixBaseId = isMix ? list.replace('RD', '') : v;
|
|
||||||
const mixListId = isMix ? list : `RD${v}`;
|
|
||||||
{
|
|
||||||
const baseInfo = isMix ? await getVideoInfo(mixBaseId) : info;
|
|
||||||
|
|
||||||
// Seed the playlist with the base video
|
|
||||||
if (baseInfo) {
|
|
||||||
playlistVideos.push({
|
|
||||||
id: mixBaseId,
|
|
||||||
title: baseInfo.title,
|
|
||||||
uploader: baseInfo.uploader,
|
|
||||||
thumbnail: baseInfo.thumbnail || `https://i.ytimg.com/vi/${mixBaseId}/hqdefault.jpg`,
|
|
||||||
view_count: baseInfo.view_count,
|
|
||||||
duration: ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-source search to build a rich playlist (15-25 videos)
|
|
||||||
const uploaderName = baseInfo?.uploader || '';
|
|
||||||
const videoTitle = baseInfo?.title || '';
|
|
||||||
const titleKeywords = videoTitle.split(/[\s\-|]+/).filter((w: string) => w.length > 2).slice(0, 4).join(' ');
|
|
||||||
|
|
||||||
const mixPromises = [
|
|
||||||
uploaderName ? getSearchVideos(uploaderName, 10) : Promise.resolve([]),
|
|
||||||
titleKeywords ? getSearchVideos(titleKeywords, 10) : Promise.resolve([]),
|
|
||||||
getRelatedVideos(mixBaseId, 10),
|
|
||||||
];
|
|
||||||
|
|
||||||
const [byUploader, byTitle, byRelated] = await Promise.all(mixPromises);
|
|
||||||
const seenMixIds = new Set(playlistVideos.map(p => p.id));
|
|
||||||
|
|
||||||
const sources = [byUploader, byTitle, byRelated];
|
|
||||||
let added = 0;
|
|
||||||
const maxPlaylist = 25;
|
|
||||||
|
|
||||||
let idx = [0, 0, 0];
|
|
||||||
while (added < maxPlaylist) {
|
|
||||||
let anyAdded = false;
|
|
||||||
for (let s = 0; s < sources.length; s++) {
|
|
||||||
while (idx[s] < sources[s].length && added < maxPlaylist) {
|
|
||||||
const vid = sources[s][idx[s]++];
|
|
||||||
if (!seenMixIds.has(vid.id)) {
|
|
||||||
seenMixIds.add(vid.id);
|
|
||||||
playlistVideos.push(vid);
|
|
||||||
added++;
|
|
||||||
anyAdded = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anyAdded) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the next video
|
|
||||||
let nextVideoId = '';
|
|
||||||
let nextListId: string | undefined = undefined;
|
|
||||||
|
|
||||||
if (playlistVideos.length > 0) {
|
|
||||||
const currentIndex = playlistVideos.findIndex(p => p.id === v);
|
|
||||||
if (currentIndex >= 0 && currentIndex < playlistVideos.length - 1) {
|
|
||||||
nextVideoId = playlistVideos[currentIndex + 1].id;
|
|
||||||
nextListId = mixListId;
|
|
||||||
} else {
|
|
||||||
nextVideoId = initialMix.length > 0 && initialMix[0].is_mix ? (initialMix[1]?.id || '') : (initialMix[0]?.id || '');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const firstRealVideo = initialMix.find(vid => !vid.is_mix);
|
|
||||||
nextVideoId = firstRealVideo ? firstRealVideo.id : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="watch-container fade-in">
|
<div className="watch-container fade-in">
|
||||||
{nextVideoId && <NextVideoClient videoId={nextVideoId} listId={nextListId} />}
|
|
||||||
<div className="watch-primary">
|
<div className="watch-primary">
|
||||||
<div className="watch-player-wrapper">
|
<div className="watch-player-wrapper">
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
|
|
@ -241,27 +97,10 @@ export default async function WatchPage({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments as a separate flex child for responsive reordering */}
|
|
||||||
<div className="comments-section">
|
|
||||||
<Comments videoId={v} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="watch-secondary">
|
<div className="watch-secondary">
|
||||||
{playlistVideos.length > 0 && (
|
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}><div className="skeleton" style={{ width: '100%', height: '100px', marginBottom: '1rem', borderRadius: '8px' }}></div><div className="skeleton" style={{ width: '100%', height: '100px', marginBottom: '1rem', borderRadius: '8px' }}></div><div className="skeleton" style={{ width: '100%', height: '100px', marginBottom: '1rem', borderRadius: '8px' }}></div></div>}>
|
||||||
<PlaylistPanel
|
<RelatedVideos videoId={v} title={info?.title || ''} uploader={info?.uploader || ''} />
|
||||||
videos={playlistVideos}
|
</Suspense>
|
||||||
currentVideoId={v}
|
|
||||||
listId={mixListId}
|
|
||||||
title={`Mix - ${playlistVideos[0]?.uploader || 'YouTube'}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<WatchFeed
|
|
||||||
videoId={v}
|
|
||||||
regionLabel={regionLabel}
|
|
||||||
initialMix={initialMix}
|
|
||||||
initialRelated={initialRelated}
|
|
||||||
initialSuggestions={initialSuggestions}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
0
frontend/eslint.config.mjs
Normal file → Executable file
9
frontend/next.config.mjs
Normal file → Executable file
|
|
@ -7,22 +7,17 @@ const nextConfig = {
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'i.ytimg.com',
|
hostname: 'i.ytimg.com',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'yt3.ggpht.com',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${apiBase}/api/:path*`,
|
destination: 'http://127.0.0.1:8080/api/:path*',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/video_proxy',
|
source: '/video_proxy',
|
||||||
destination: `${apiBase}/video_proxy`,
|
destination: 'http://127.0.0.1:8080/video_proxy',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
|
||||||
12
frontend/package-lock.json
generated
Normal file → Executable file
|
|
@ -76,7 +76,6 @@
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
|
|
@ -1616,7 +1615,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -1682,7 +1680,6 @@
|
||||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.0",
|
"@typescript-eslint/scope-manager": "8.56.0",
|
||||||
"@typescript-eslint/types": "8.56.0",
|
"@typescript-eslint/types": "8.56.0",
|
||||||
|
|
@ -2211,7 +2208,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -2564,7 +2560,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -3138,7 +3133,6 @@
|
||||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -3324,7 +3318,6 @@
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|
@ -5564,7 +5557,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5574,7 +5566,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -6272,7 +6263,6 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -6435,7 +6425,6 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -6745,7 +6734,6 @@
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
0
frontend/package.json
Normal file → Executable file
0
frontend/postcss.config.mjs
Normal file → Executable file
|
Before Width: | Height: | Size: 3.9 KiB |
0
frontend/public/file.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
0
frontend/public/globe.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
|
@ -1,24 +0,0 @@
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 976 B |
|
Before Width: | Height: | Size: 14 KiB |
|
|
@ -1,109 +0,0 @@
|
||||||
{
|
|
||||||
"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
Normal file → Executable file
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,83 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
// 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
Normal file → Executable file
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
0
frontend/public/window.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
|
|
@ -1,63 +0,0 @@
|
||||||
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();
|
|
||||||