Fix audio playback, sidebar overlap, and UI highlights
This commit is contained in:
parent
66d95e0fb4
commit
c49d827296
13 changed files with 365 additions and 88 deletions
|
|
@ -5,6 +5,7 @@ A modern, fast, and fully-featured YouTube-like video streaming platform. Built
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **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.
|
||||||
- **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, with smart video suggestions.
|
- **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).
|
- **Region Selection**: Tailor your content to specific regions (e.g., Vietnam).
|
||||||
|
|
|
||||||
|
|
@ -108,16 +108,13 @@ func handleGetStreamInfo(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := services.GetVideoInfo(videoID)
|
info, qualities, audioURL, err := services.GetFullStreamData(videoID)
|
||||||
if err != nil {
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get available qualities with audio
|
|
||||||
qualities, audioURL, _ := services.GetVideoQualitiesWithAudio(videoID)
|
|
||||||
|
|
||||||
// Build quality options for frontend
|
// Build quality options for frontend
|
||||||
var qualityOptions []gin.H
|
var qualityOptions []gin.H
|
||||||
bestURL := info.StreamURL
|
bestURL := info.StreamURL
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,201 @@ func GetVideoQualitiesWithAudio(videoID string) ([]QualityFormat, string, error)
|
||||||
return qualities, audioURL, nil
|
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) {
|
func GetStreamURLForQuality(videoID string, height int) (string, error) {
|
||||||
qualities, err := GetVideoQualities(videoID)
|
qualities, err := GetVideoQualities(videoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ services:
|
||||||
kv-tube-backend:
|
kv-tube-backend:
|
||||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube-backend:v4.0.0
|
image: git.khoavo.myds.me/vndangkhoa/kv-tube-backend:v4.0.0
|
||||||
container_name: kv-tube-backend
|
container_name: kv-tube-backend
|
||||||
|
platform: linux/amd64
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
@ -25,6 +26,7 @@ services:
|
||||||
kv-tube-frontend:
|
kv-tube-frontend:
|
||||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube-frontend:v4.0.0
|
image: git.khoavo.myds.me/vndangkhoa/kv-tube-frontend:v4.0.0
|
||||||
container_name: kv-tube-frontend
|
container_name: kv-tube-frontend
|
||||||
|
platform: linux/amd64
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5011:3000"
|
- "5011:3000"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
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';
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const videoId = request.nextUrl.searchParams.get('v');
|
const videoId = request.nextUrl.searchParams.get('v');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,15 @@ export default function Sidebar() {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '16px 0 14px 0',
|
padding: '16px 0 14px 0',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
backgroundColor: isActive ? 'var(--yt-hover)' : 'transparent',
|
backgroundColor: 'transparent',
|
||||||
marginBottom: '4px',
|
marginBottom: '4px',
|
||||||
transition: 'var(--yt-transition)',
|
transition: 'var(--yt-transition)',
|
||||||
gap: '4px',
|
gap: '4px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
width: '100%'
|
||||||
}}
|
}}
|
||||||
className="yt-sidebar-item"
|
className="yt-sidebar-item"
|
||||||
>
|
>
|
||||||
{isActive && <div className="sidebar-active-indicator" />}
|
|
||||||
<div style={{ color: 'var(--yt-text-primary)', transition: 'transform 0.15s ease' }}>
|
<div style={{ color: 'var(--yt-text-primary)', transition: 'transform 0.15s ease' }}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface VideoData {
|
interface VideoData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -25,10 +28,15 @@ function getRelativeTime(id: string): string {
|
||||||
|
|
||||||
export default function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
|
export default 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);
|
||||||
|
|
||||||
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 href={`/watch?v=${video.id}`} style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}>
|
<Link
|
||||||
|
href={`/watch?v=${video.id}`}
|
||||||
|
onClick={() => 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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={video.thumbnail}
|
src={video.thumbnail}
|
||||||
|
|
@ -41,6 +49,23 @@ export default function VideoCard({ video, hideChannelAvatar }: { video: VideoDa
|
||||||
{video.duration}
|
{video.duration}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isNavigating && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 10
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '40px', height: '40px',
|
||||||
|
border: '3px solid rgba(255,255,255,0.3)',
|
||||||
|
borderTopColor: '#fff',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '12px', padding: '0 12px' }} className="videocard-info">
|
<div style={{ display: 'flex', gap: '12px', padding: '0 12px' }} className="videocard-info">
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 12px 4px;
|
padding: 12px 4px;
|
||||||
z-index: 400;
|
z-index: 600;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1384,4 +1384,14 @@ a {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
}@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/app/watch/NextVideoClient.tsx
Normal file
14
frontend/app/watch/NextVideoClient.tsx
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
69
frontend/app/watch/RelatedVideos.tsx
Normal file
69
frontend/app/watch/RelatedVideos.tsx
Normal file
|
|
@ -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<VideoData[]>;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatViews(views: number): string {
|
||||||
|
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
||||||
|
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
|
||||||
|
return views.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div style={{ padding: '1rem', color: '#888' }}>No related videos found.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVideoId = relatedVideos[0].id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="watch-related-list">
|
||||||
|
<NextVideoClient videoId={nextVideoId} />
|
||||||
|
{relatedVideos.map((video, i) => {
|
||||||
|
const views = formatViews(video.view_count);
|
||||||
|
const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}>
|
||||||
|
<div className="related-thumb-container">
|
||||||
|
<img src={video.thumbnail} alt={video.title} className="related-thumb-img" />
|
||||||
|
{video.duration && (
|
||||||
|
<div className="duration-badge">
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="related-video-info">
|
||||||
|
<span className="related-video-title">{video.title}</span>
|
||||||
|
<span className="related-video-channel">{video.uploader}</span>
|
||||||
|
<span className="related-video-meta">{views} views</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,6 @@ declare global {
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
nextVideoId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QualityOption {
|
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 router = useRouter();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
@ -71,8 +70,19 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
|
||||||
const [hasSeparateAudio, setHasSeparateAudio] = useState(false);
|
const [hasSeparateAudio, setHasSeparateAudio] = useState(false);
|
||||||
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 audioUrlRef = useRef<string>('');
|
const audioUrlRef = useRef<string>('');
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
|
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) {
|
if (video.paused && !audio.paused) {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
} else if (!video.paused && audio.paused) {
|
} 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 (isHLS && window.Hls && window.Hls.isSupported()) {
|
||||||
if (hlsRef.current) hlsRef.current.destroy();
|
if (hlsRef.current) hlsRef.current.destroy();
|
||||||
|
|
||||||
const hls = new window.Hls({
|
const hls = new window.Hls({
|
||||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||||
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
|
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
hlsRef.current = hls;
|
hlsRef.current = hls;
|
||||||
|
|
||||||
hls.loadSource(streamUrl);
|
hls.loadSource(streamUrl);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
|
||||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => { });
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.on(window.Hls.Events.ERROR, (_: any, data: any) => {
|
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')) {
|
} 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 {
|
||||||
video.src = streamUrl;
|
video.src = streamUrl;
|
||||||
video.onloadeddata = () => video.play().catch(() => {});
|
video.onloadeddata = () => video.play().catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsSeparateAudio) {
|
if (needsSeparateAudio) {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (audio) {
|
if (audio) {
|
||||||
const audioIsHLS = audioStreamUrl!.includes('.m3u8') || audioStreamUrl!.includes('manifest');
|
const audioIsHLS = audioStreamUrl!.includes('.m3u8') || audioStreamUrl!.includes('manifest');
|
||||||
|
|
||||||
if (audioIsHLS && window.Hls && window.Hls.isSupported()) {
|
if (audioIsHLS && window.Hls && window.Hls.isSupported()) {
|
||||||
if (audioHlsRef.current) audioHlsRef.current.destroy();
|
if (audioHlsRef.current) audioHlsRef.current.destroy();
|
||||||
|
|
||||||
const audioHls = new window.Hls({
|
const audioHls = new window.Hls({
|
||||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||||
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
|
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
audioHlsRef.current = audioHls;
|
audioHlsRef.current = audioHls;
|
||||||
|
|
||||||
audioHls.loadSource(audioStreamUrl!);
|
audioHls.loadSource(audioStreamUrl!);
|
||||||
audioHls.attachMedia(audio);
|
audioHls.attachMedia(audio);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -268,12 +278,12 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
|
||||||
setCurrentQuality(quality.height);
|
setCurrentQuality(quality.height);
|
||||||
|
|
||||||
video.currentTime = currentTime;
|
video.currentTime = currentTime;
|
||||||
if (wasPlaying) video.play().catch(() => {});
|
if (wasPlaying) video.play().catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!useFallback) return;
|
if (!useFallback) return;
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
if (event.origin !== 'https://www.youtube.com') return;
|
if (event.origin !== 'https://www.youtube.com') return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -281,7 +291,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
|
||||||
if (data.event === 'onStateChange' && data.info === 0 && nextVideoId) {
|
if (data.event === 'onStateChange' && data.info === 0 && nextVideoId) {
|
||||||
router.push(`/watch?v=${nextVideoId}`);
|
router.push(`/watch?v=${nextVideoId}`);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
};
|
};
|
||||||
window.addEventListener('message', handleMessage);
|
window.addEventListener('message', handleMessage);
|
||||||
return () => window.removeEventListener('message', handleMessage);
|
return () => window.removeEventListener('message', handleMessage);
|
||||||
|
|
@ -308,7 +318,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
|
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
|
||||||
{isLoading && <PlayerSkeleton />}
|
{isLoading && <PlayerSkeleton />}
|
||||||
|
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
|
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
|
||||||
|
|
@ -316,16 +326,16 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
|
||||||
playsInline
|
playsInline
|
||||||
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasSeparateAudio && <audio ref={audioRef} style={{ display: 'none' }} />}
|
<audio ref={audioRef} style={{ display: 'none' }} />
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={errorStyle}>
|
<div style={errorStyle}>
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
<button onClick={() => setUseFallback(true)} style={retryBtnStyle}>Try YouTube Player</button>
|
<button onClick={() => setUseFallback(true)} style={retryBtnStyle}>Try YouTube Player</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showControls && !error && !isLoading && (
|
{showControls && !error && !isLoading && (
|
||||||
<>
|
<>
|
||||||
<a href={`https://www.youtube.com/watch?v=${videoId}`} target="_blank" rel="noopener noreferrer" style={openBtnStyle}>
|
<a href={`https://www.youtube.com/watch?v=${videoId}`} target="_blank" rel="noopener noreferrer" style={openBtnStyle}>
|
||||||
|
|
@ -337,7 +347,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
|
||||||
<button onClick={() => setShowQualityMenu(!showQualityMenu)} style={qualityBtnStyle}>
|
<button onClick={() => setShowQualityMenu(!showQualityMenu)} style={qualityBtnStyle}>
|
||||||
{qualities.find(q => q.height === currentQuality)?.label || 'Auto'}
|
{qualities.find(q => q.height === currentQuality)?.label || 'Auto'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showQualityMenu && (
|
{showQualityMenu && (
|
||||||
<div style={qualityMenuStyle}>
|
<div style={qualityMenuStyle}>
|
||||||
{qualities.map((q) => (
|
{qualities.map((q) => (
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,11 @@
|
||||||
|
import { Suspense } from 'react';
|
||||||
import VideoPlayer from './VideoPlayer';
|
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 RelatedVideos from './RelatedVideos';
|
||||||
import { API_BASE } from '../constants';
|
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 {
|
interface VideoInfo {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -42,24 +34,6 @@ async function getVideoInfo(id: string): Promise<VideoInfo | null> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRelatedVideos(videoId: string, title: string, uploader: string) {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ v: videoId, title: title || '', uploader: uploader || '', limit: '15' });
|
|
||||||
const res = await fetch(`${API_BASE}/api/related?${params.toString()}`, { cache: 'no-store' });
|
|
||||||
if (!res.ok) return [];
|
|
||||||
return res.json() as Promise<VideoData[]>;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatViews(views: number): string {
|
|
||||||
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
|
||||||
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
|
|
||||||
return views.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
function formatNumber(num: number): string {
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||||
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
||||||
|
|
@ -79,17 +53,14 @@ export default async function WatchPage({
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await getVideoInfo(v);
|
const info = await getVideoInfo(v);
|
||||||
const relatedVideos = await getRelatedVideos(v, info?.title || '', info?.uploader || '');
|
|
||||||
const nextVideoId = relatedVideos.length > 0 ? relatedVideos[0].id : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="watch-container fade-in">
|
<div className="watch-container fade-in">
|
||||||
<div className="watch-primary">
|
<div className="watch-primary">
|
||||||
<div className="watch-player-wrapper">
|
<div className="watch-player-wrapper">
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
videoId={v}
|
videoId={v}
|
||||||
title={info?.title}
|
title={info?.title}
|
||||||
nextVideoId={nextVideoId}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -127,30 +98,9 @@ export default async function WatchPage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="watch-secondary">
|
<div className="watch-secondary">
|
||||||
<div className="watch-related-list">
|
<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>}>
|
||||||
{relatedVideos.map((video, i) => {
|
<RelatedVideos videoId={v} title={info?.title || ''} uploader={info?.uploader || ''} />
|
||||||
const views = formatViews(video.view_count);
|
</Suspense>
|
||||||
const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}>
|
|
||||||
<div className="related-thumb-container">
|
|
||||||
<img src={video.thumbnail} alt={video.title} className="related-thumb-img" />
|
|
||||||
{video.duration && (
|
|
||||||
<div className="duration-badge">
|
|
||||||
{video.duration}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="related-video-info">
|
|
||||||
<span className="related-video-title">{video.title}</span>
|
|
||||||
<span className="related-video-channel">{video.uploader}</span>
|
|
||||||
<span className="related-video-meta">{views} views</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue