From c49d8272969bd68ffea31c6727a78a04b33dc37f Mon Sep 17 00:00:00 2001 From: KV-Tube Deployer Date: Sun, 22 Feb 2026 21:04:48 +0700 Subject: [PATCH] Fix audio playback, sidebar overlap, and UI highlights --- README.md | 1 + backend/routes/api.go | 7 +- backend/services/ytdlp.go | 195 +++++++++++++++++++++++++ docker-compose.yml | 2 + frontend/app/api/download/route.ts | 2 + frontend/app/api/stream/route.ts | 2 + frontend/app/components/Sidebar.tsx | 4 +- frontend/app/components/VideoCard.tsx | 27 +++- frontend/app/globals.css | 14 +- frontend/app/watch/NextVideoClient.tsx | 14 ++ frontend/app/watch/RelatedVideos.tsx | 69 +++++++++ frontend/app/watch/VideoPlayer.tsx | 52 ++++--- frontend/app/watch/page.tsx | 64 +------- 13 files changed, 365 insertions(+), 88 deletions(-) create mode 100644 frontend/app/watch/NextVideoClient.tsx create mode 100644 frontend/app/watch/RelatedVideos.tsx 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.title} + {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 && } - +