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
|
||||
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && <div className="sidebar-active-indicator" />}
|
||||
<div style={{ color: 'var(--yt-text-primary)', transition: 'transform 0.15s ease' }}>
|
||||
{item.icon}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 */}
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
|
|
@ -41,6 +49,23 @@ export default function VideoCard({ video, hideChannelAvatar }: { video: VideoDa
|
|||
{video.duration}
|
||||
</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>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', padding: '0 12px' }} className="videocard-info">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(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<string | undefined>();
|
||||
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(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
|
||||
|
|
@ -317,7 +327,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
|
|||
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
||||
/>
|
||||
|
||||
{hasSeparateAudio && <audio ref={audioRef} style={{ display: 'none' }} />}
|
||||
<audio ref={audioRef} style={{ display: 'none' }} />
|
||||
|
||||
{error && (
|
||||
<div style={errorStyle}>
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
||||
|
|
@ -79,8 +53,6 @@ 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 (
|
||||
<div className="watch-container fade-in">
|
||||
|
|
@ -89,7 +61,6 @@ export default async function WatchPage({
|
|||
<VideoPlayer
|
||||
videoId={v}
|
||||
title={info?.title}
|
||||
nextVideoId={nextVideoId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -127,30 +98,9 @@ export default async function WatchPage({
|
|||
</div>
|
||||
|
||||
<div className="watch-secondary">
|
||||
<div className="watch-related-list">
|
||||
{relatedVideos.map((video, i) => {
|
||||
const views = formatViews(video.view_count);
|
||||
const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
|
||||
|
||||
return (
|
||||
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}>
|
||||
<div className="related-thumb-container">
|
||||
<img src={video.thumbnail} alt={video.title} className="related-thumb-img" />
|
||||
{video.duration && (
|
||||
<div className="duration-badge">
|
||||
{video.duration}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="related-video-info">
|
||||
<span className="related-video-title">{video.title}</span>
|
||||
<span className="related-video-channel">{video.uploader}</span>
|
||||
<span className="related-video-meta">{views} views</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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 videoId={v} title={info?.title || ''} uploader={info?.uploader || ''} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue