diff --git a/README.md b/README.md
index aecaf59..6d16dda 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@ A modern, fast, and fully-featured YouTube-like video streaming platform. Built
## Features
- **Modern Video Player**: High-resolution video playback with HLS support and quality selection.
+- **Fast Navigation**: Instant click feedback with skeleton loaders for related videos.
- **Infinite Scrolling**: Scroll seamlessly through a dynamic video grid on the homepage.
- **Watch History & Suggestions**: Keep track of what you've watched, with smart video suggestions.
- **Region Selection**: Tailor your content to specific regions (e.g., Vietnam).
diff --git a/backend/routes/api.go b/backend/routes/api.go
index 5cc72da..a43cc9c 100755
--- a/backend/routes/api.go
+++ b/backend/routes/api.go
@@ -108,16 +108,13 @@ func handleGetStreamInfo(c *gin.Context) {
return
}
- info, err := services.GetVideoInfo(videoID)
+ info, qualities, audioURL, err := services.GetFullStreamData(videoID)
if err != nil {
- log.Printf("GetVideoInfo Error: %v", err)
+ log.Printf("GetFullStreamData Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"})
return
}
- // Get available qualities with audio
- qualities, audioURL, _ := services.GetVideoQualitiesWithAudio(videoID)
-
// Build quality options for frontend
var qualityOptions []gin.H
bestURL := info.StreamURL
diff --git a/backend/services/ytdlp.go b/backend/services/ytdlp.go
index 23b1305..7fc5557 100755
--- a/backend/services/ytdlp.go
+++ b/backend/services/ytdlp.go
@@ -461,6 +461,201 @@ func GetVideoQualitiesWithAudio(videoID string) ([]QualityFormat, string, error)
return qualities, audioURL, nil
}
+// GetFullStreamData runs a single yt-dlp command to fetch all essential information at once
+// This avoids doing 3 separate slow calls for video info, qualities, and best audio.
+func GetFullStreamData(videoID string) (*VideoData, []QualityFormat, string, error) {
+ urlStr := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
+
+ cmdArgs := []string{
+ "--dump-json",
+ "--no-warnings",
+ "--quiet",
+ "--force-ipv4",
+ "--no-playlist",
+ "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ urlStr,
+ }
+
+ binPath := "yt-dlp"
+ if _, err := exec.LookPath("yt-dlp"); err != nil {
+ fallbacks := []string{
+ os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
+ os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
+ os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
+ os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
+ "/usr/local/bin/yt-dlp",
+ "/opt/homebrew/bin/yt-dlp",
+ "/config/.local/bin/yt-dlp",
+ }
+ for _, fb := range fallbacks {
+ if _, err := os.Stat(fb); err == nil {
+ binPath = fb
+ break
+ }
+ }
+ }
+
+ cmd := exec.Command(binPath, cmdArgs...)
+
+ var out bytes.Buffer
+ var stderr bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ log.Printf("yt-dlp error in GetFullStreamData: %v, stderr: %s", err, stderr.String())
+ return nil, nil, "", err
+ }
+
+ // Unmarshal common metadata
+ var entry YtDlpEntry
+ if err := json.Unmarshal(out.Bytes(), &entry); err != nil {
+ return nil, nil, "", err
+ }
+
+ videoData := sanitizeVideoData(entry)
+ videoData.StreamURL = entry.URL
+
+ // Unmarshal formats specifically
+ var raw struct {
+ Formats []struct {
+ FormatID string `json:"format_id"`
+ FormatNote string `json:"format_note"`
+ Ext string `json:"ext"`
+ Resolution string `json:"resolution"`
+ Width interface{} `json:"width"`
+ Height interface{} `json:"height"`
+ URL string `json:"url"`
+ ManifestURL string `json:"manifest_url"`
+ VCodec string `json:"vcodec"`
+ ACodec string `json:"acodec"`
+ Filesize interface{} `json:"filesize"`
+ ABR interface{} `json:"abr"`
+ } `json:"formats"`
+ }
+
+ if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
+ return nil, nil, "", err
+ }
+
+ var qualities []QualityFormat
+ seen := make(map[int]int) // height -> index in qualities
+ var bestAudio string
+ var bestABR float64
+
+ for _, f := range raw.Formats {
+ // Determine if it's the best audio
+ if f.VCodec == "none" && f.ACodec != "none" && f.URL != "" {
+ var abr float64
+ switch v := f.ABR.(type) {
+ case float64:
+ abr = v
+ case int:
+ abr = float64(v)
+ }
+ if bestAudio == "" || abr > bestABR {
+ bestABR = abr
+ bestAudio = f.URL
+ }
+ }
+
+ if f.VCodec == "none" || f.URL == "" {
+ continue
+ }
+
+ var height int
+ switch v := f.Height.(type) {
+ case float64:
+ height = int(v)
+ case int:
+ height = v
+ }
+
+ if height == 0 {
+ continue
+ }
+
+ hasAudio := f.ACodec != "none" && f.ACodec != ""
+
+ var filesize int64
+ switch v := f.Filesize.(type) {
+ case float64:
+ filesize = int64(v)
+ case int64:
+ filesize = v
+ }
+
+ isHLS := f.ManifestURL != "" || strings.Contains(f.URL, ".m3u8") || strings.Contains(f.URL, "manifest")
+
+ label := f.FormatNote
+ if label == "" {
+ switch height {
+ case 2160:
+ label = "4K"
+ case 1440:
+ label = "1440p"
+ case 1080:
+ label = "1080p"
+ case 720:
+ label = "720p"
+ case 480:
+ label = "480p"
+ case 360:
+ label = "360p"
+ default:
+ label = fmt.Sprintf("%dp", height)
+ }
+ }
+
+ streamURL := f.URL
+ if f.ManifestURL != "" {
+ streamURL = f.ManifestURL
+ }
+
+ qf := QualityFormat{
+ FormatID: f.FormatID,
+ Label: label,
+ Resolution: f.Resolution,
+ Height: height,
+ URL: streamURL,
+ IsHLS: isHLS,
+ VCodec: f.VCodec,
+ ACodec: f.ACodec,
+ Filesize: filesize,
+ HasAudio: hasAudio,
+ }
+
+ // Prefer formats with audio, otherwise just add
+ if idx, exists := seen[height]; exists {
+ // Replace if this one has audio and the existing one doesn't
+ if hasAudio && !qualities[idx].HasAudio {
+ qualities[idx] = qf
+ }
+ } else {
+ seen[height] = len(qualities)
+ qualities = append(qualities, qf)
+ }
+ }
+
+ // Sort by height descending
+ for i := range qualities {
+ for j := i + 1; j < len(qualities); j++ {
+ if qualities[j].Height > qualities[i].Height {
+ qualities[i], qualities[j] = qualities[j], qualities[i]
+ }
+ }
+ }
+
+ // Attach audio URL to qualities without audio
+ for i := range qualities {
+ if !qualities[i].HasAudio && bestAudio != "" {
+ qualities[i].AudioURL = bestAudio
+ }
+ }
+
+ return &videoData, qualities, bestAudio, nil
+}
+
func GetStreamURLForQuality(videoID string, height int) (string, error) {
qualities, err := GetVideoQualities(videoID)
if err != nil {
diff --git a/docker-compose.yml b/docker-compose.yml
index ff93e80..fc4106d 100755
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,6 +7,7 @@ services:
kv-tube-backend:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-backend:v4.0.0
container_name: kv-tube-backend
+ platform: linux/amd64
restart: unless-stopped
volumes:
- ./data:/app/data
@@ -25,6 +26,7 @@ services:
kv-tube-frontend:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-frontend:v4.0.0
container_name: kv-tube-frontend
+ platform: linux/amd64
restart: unless-stopped
ports:
- "5011:3000"
diff --git a/frontend/app/api/download/route.ts b/frontend/app/api/download/route.ts
index 663142b..bcb4afa 100755
--- a/frontend/app/api/download/route.ts
+++ b/frontend/app/api/download/route.ts
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
+export const dynamic = 'force-dynamic';
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
export async function GET(request: NextRequest) {
diff --git a/frontend/app/api/stream/route.ts b/frontend/app/api/stream/route.ts
index f6c3296..d3c60df 100755
--- a/frontend/app/api/stream/route.ts
+++ b/frontend/app/api/stream/route.ts
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
+export const dynamic = 'force-dynamic';
+
export async function GET(request: NextRequest) {
const videoId = request.nextUrl.searchParams.get('v');
diff --git a/frontend/app/components/Sidebar.tsx b/frontend/app/components/Sidebar.tsx
index cd7f6e5..ed374d4 100755
--- a/frontend/app/components/Sidebar.tsx
+++ b/frontend/app/components/Sidebar.tsx
@@ -30,15 +30,15 @@ export default function Sidebar() {
justifyContent: 'center',
padding: '16px 0 14px 0',
borderRadius: '10px',
- backgroundColor: isActive ? 'var(--yt-hover)' : 'transparent',
+ backgroundColor: 'transparent',
marginBottom: '4px',
transition: 'var(--yt-transition)',
gap: '4px',
position: 'relative',
+ width: '100%'
}}
className="yt-sidebar-item"
>
- {isActive &&
}
{item.icon}
diff --git a/frontend/app/components/VideoCard.tsx b/frontend/app/components/VideoCard.tsx
index 1bb925c..1a7730b 100755
--- a/frontend/app/components/VideoCard.tsx
+++ b/frontend/app/components/VideoCard.tsx
@@ -1,4 +1,7 @@
+'use client';
+
import Link from 'next/link';
+import { useState } from 'react';
interface VideoData {
id: string;
@@ -25,10 +28,15 @@ function getRelativeTime(id: string): string {
export default function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
+ const [isNavigating, setIsNavigating] = useState(false);
return (
-
+
setIsNavigating(true)}
+ style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}
+ >
{/* eslint-disable-next-line @next/next/no-img-element */}

)}
+
+ {isNavigating && (
+
+ )}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 4528fc5..a3d981b 100755
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -251,7 +251,7 @@
align-items: center;
justify-content: center;
padding: 12px 4px;
- z-index: 400;
+ z-index: 600;
gap: 4px;
}
@@ -1384,4 +1384,14 @@ a {
grid-template-columns: 1fr;
gap: 0;
}
-}@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/watch/NextVideoClient.tsx b/frontend/app/watch/NextVideoClient.tsx
new file mode 100644
index 0000000..9ded6bb
--- /dev/null
+++ b/frontend/app/watch/NextVideoClient.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export default function NextVideoClient({ videoId }: { videoId: string }) {
+ useEffect(() => {
+ if (typeof window !== 'undefined' && videoId) {
+ const event = new CustomEvent('setNextVideoId', { detail: { videoId } });
+ window.dispatchEvent(event);
+ }
+ }, [videoId]);
+
+ return null;
+}
diff --git a/frontend/app/watch/RelatedVideos.tsx b/frontend/app/watch/RelatedVideos.tsx
new file mode 100644
index 0000000..13185bf
--- /dev/null
+++ b/frontend/app/watch/RelatedVideos.tsx
@@ -0,0 +1,69 @@
+import Link from 'next/link';
+import { API_BASE } from '../constants';
+import NextVideoClient from './NextVideoClient';
+
+interface VideoData {
+ id: string;
+ title: string;
+ uploader: string;
+ channel_id?: string;
+ thumbnail: string;
+ view_count: number;
+ duration: string;
+}
+
+async function getRelatedVideos(videoId: string, title: string, uploader: string) {
+ try {
+ const params = new URLSearchParams({ v: videoId, title: title || '', uploader: uploader || '', limit: '15' });
+ const res = await fetch(`${API_BASE}/api/related?${params.toString()}`, { cache: 'no-store' });
+ if (!res.ok) return [];
+ return res.json() as Promise
;
+ } catch (e) {
+ console.error(e);
+ return [];
+ }
+}
+
+function formatViews(views: number): string {
+ if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
+ if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
+ return views.toString();
+}
+
+export default async function RelatedVideos({ videoId, title, uploader }: { videoId: string, title: string, uploader: string }) {
+ const relatedVideos = await getRelatedVideos(videoId, title, uploader);
+
+ if (relatedVideos.length === 0) {
+ return No related videos found.
;
+ }
+
+ const nextVideoId = relatedVideos[0].id;
+
+ return (
+
+
+ {relatedVideos.map((video, i) => {
+ const views = formatViews(video.view_count);
+ const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
+
+ return (
+
+
+

+ {video.duration && (
+
+ {video.duration}
+
+ )}
+
+
+ {video.title}
+ {video.uploader}
+ {views} views
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/app/watch/VideoPlayer.tsx b/frontend/app/watch/VideoPlayer.tsx
index 26badf4..9d0c1ef 100755
--- a/frontend/app/watch/VideoPlayer.tsx
+++ b/frontend/app/watch/VideoPlayer.tsx
@@ -12,7 +12,6 @@ declare global {
interface VideoPlayerProps {
videoId: string;
title?: string;
- nextVideoId?: string;
}
interface QualityOption {
@@ -56,7 +55,7 @@ function PlayerSkeleton() {
);
}
-export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayerProps) {
+export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
const router = useRouter();
const videoRef = useRef(null);
const audioRef = useRef(null);
@@ -71,8 +70,19 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
const [hasSeparateAudio, setHasSeparateAudio] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isBuffering, setIsBuffering] = useState(false);
+ const [nextVideoId, setNextVideoId] = useState();
const audioUrlRef = useRef('');
+ useEffect(() => {
+ const handleSetNextVideo = (e: CustomEvent) => {
+ if (e.detail && e.detail.videoId) {
+ setNextVideoId(e.detail.videoId);
+ }
+ };
+ window.addEventListener('setNextVideoId', handleSetNextVideo as EventListener);
+ return () => window.removeEventListener('setNextVideoId', handleSetNextVideo as EventListener);
+ }, []);
+
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
@@ -94,7 +104,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
if (video.paused && !audio.paused) {
audio.pause();
} else if (!video.paused && audio.paused) {
- audio.play().catch(() => {});
+ audio.play().catch(() => { });
}
};
@@ -195,19 +205,19 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
if (isHLS && window.Hls && window.Hls.isSupported()) {
if (hlsRef.current) hlsRef.current.destroy();
-
+
const hls = new window.Hls({
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
});
hlsRef.current = hls;
-
+
hls.loadSource(streamUrl);
hls.attachMedia(video);
-
+
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
- video.play().catch(() => {});
+ video.play().catch(() => { });
});
hls.on(window.Hls.Events.ERROR, (_: any, data: any) => {
@@ -219,27 +229,27 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = streamUrl;
- video.onloadedmetadata = () => video.play().catch(() => {});
+ video.onloadedmetadata = () => video.play().catch(() => { });
} else {
video.src = streamUrl;
- video.onloadeddata = () => video.play().catch(() => {});
+ video.onloadeddata = () => video.play().catch(() => { });
}
if (needsSeparateAudio) {
const audio = audioRef.current;
if (audio) {
const audioIsHLS = audioStreamUrl!.includes('.m3u8') || audioStreamUrl!.includes('manifest');
-
+
if (audioIsHLS && window.Hls && window.Hls.isSupported()) {
if (audioHlsRef.current) audioHlsRef.current.destroy();
-
+
const audioHls = new window.Hls({
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
});
audioHlsRef.current = audioHls;
-
+
audioHls.loadSource(audioStreamUrl!);
audioHls.attachMedia(audio);
} else {
@@ -268,12 +278,12 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
setCurrentQuality(quality.height);
video.currentTime = currentTime;
- if (wasPlaying) video.play().catch(() => {});
+ if (wasPlaying) video.play().catch(() => { });
};
useEffect(() => {
if (!useFallback) return;
-
+
const handleMessage = (event: MessageEvent) => {
if (event.origin !== 'https://www.youtube.com') return;
try {
@@ -281,7 +291,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
if (data.event === 'onStateChange' && data.info === 0 && nextVideoId) {
router.push(`/watch?v=${nextVideoId}`);
}
- } catch {}
+ } catch { }
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
@@ -308,7 +318,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
return (
setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
{isLoading &&
}
-
+
-
- {hasSeparateAudio &&
}
-
+
+
+
{error && (
{error}
)}
-
+
{showControls && !error && !isLoading && (
<>
@@ -337,7 +347,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
-
+
{showQualityMenu && (
{qualities.map((q) => (
diff --git a/frontend/app/watch/page.tsx b/frontend/app/watch/page.tsx
index fd8a0cc..b9e862b 100755
--- a/frontend/app/watch/page.tsx
+++ b/frontend/app/watch/page.tsx
@@ -1,19 +1,11 @@
+import { Suspense } from 'react';
import VideoPlayer from './VideoPlayer';
import Link from 'next/link';
import WatchActions from './WatchActions';
import SubscribeButton from '../components/SubscribeButton';
+import RelatedVideos from './RelatedVideos';
import { API_BASE } from '../constants';
-interface VideoData {
- id: string;
- title: string;
- uploader: string;
- channel_id?: string;
- thumbnail: string;
- view_count: number;
- duration: string;
-}
-
interface VideoInfo {
title: string;
description: string;
@@ -42,24 +34,6 @@ async function getVideoInfo(id: string): Promise
{
}
}
-async function getRelatedVideos(videoId: string, title: string, uploader: string) {
- try {
- const params = new URLSearchParams({ v: videoId, title: title || '', uploader: uploader || '', limit: '15' });
- const res = await fetch(`${API_BASE}/api/related?${params.toString()}`, { cache: 'no-store' });
- if (!res.ok) return [];
- return res.json() as Promise;
- } catch (e) {
- console.error(e);
- return [];
- }
-}
-
-function formatViews(views: number): string {
- if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
- if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
- return views.toString();
-}
-
function formatNumber(num: number): string {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
@@ -79,17 +53,14 @@ export default async function WatchPage({
}
const info = await getVideoInfo(v);
- const relatedVideos = await getRelatedVideos(v, info?.title || '', info?.uploader || '');
- const nextVideoId = relatedVideos.length > 0 ? relatedVideos[0].id : undefined;
return (
-
@@ -127,30 +98,9 @@ export default async function WatchPage({
-
- {relatedVideos.map((video, i) => {
- const views = formatViews(video.view_count);
- const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
-
- return (
-
-
-

- {video.duration && (
-
- {video.duration}
-
- )}
-
-
- {video.title}
- {video.uploader}
- {views} views
-
-
- );
- })}
-
+
}>
+
+
);