kv-tube/frontend/app/watch/ClientWatchPage.tsx
2026-03-26 13:11:20 +07:00

996 lines
No EOL
44 KiB
TypeScript

'use client';
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import YouTubePlayer from './YouTubePlayer';
import { getVideoDetailsClient, getRelatedVideosClient, getCommentsClient, searchVideosClient } from '../clientActions';
import { VideoData } from '../constants';
import { isSubscribed, toggleSubscription, addToHistory, isVideoSaved, toggleSaveVideo } from '../storage';
import LoadingSpinner from '../components/LoadingSpinner';
import Link from 'next/link';
// Simple cache for API responses to reduce quota usage
const apiCache = new Map<string, { data: any; timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
function getCachedData(key: string) {
const cached = apiCache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return cached.data;
}
return null;
}
function setCachedData(key: string, data: any) {
apiCache.set(key, { data, timestamp: Date.now() });
// Clean up old cache entries
if (apiCache.size > 100) {
const oldestKey = apiCache.keys().next().value;
if (oldestKey) {
apiCache.delete(oldestKey);
}
}
}
// Video Info Section
function VideoInfo({ video }: { video: any }) {
const [expanded, setExpanded] = useState(false);
const [subscribed, setSubscribed] = useState(false);
const [isSaved, setIsSaved] = useState(false);
const [subscribing, setSubscribing] = useState(false);
// Check subscription and save status on mount
useEffect(() => {
if (video?.channelId) {
setSubscribed(isSubscribed(video.channelId));
}
if (video?.id) {
setIsSaved(isVideoSaved(video.id));
}
}, [video?.channelId, video?.id]);
const handleSubscribe = useCallback(() => {
if (!video?.channelId || subscribing) return;
setSubscribing(true);
try {
const nowSubscribed = toggleSubscription({
channelId: video.channelId,
channelName: video.channelTitle,
channelAvatar: '',
});
setSubscribed(nowSubscribed);
} catch (error) {
console.error('Subscribe error:', error);
} finally {
setSubscribing(false);
}
}, [video?.channelId, video?.channelTitle, subscribing]);
const handleSave = useCallback(() => {
if (!video?.id) return;
try {
const nowSaved = toggleSaveVideo({
videoId: video.id,
title: video.title,
thumbnail: video.thumbnail,
channelTitle: video.channelTitle,
});
setIsSaved(nowSaved);
} catch (error) {
console.error('Save error:', error);
}
}, [video?.id, video?.title, video?.thumbnail, video?.channelTitle]);
if (!video) return null;
const description = video.description || '';
const hasDescription = description.length > 0;
const shouldTruncate = description.length > 300;
const displayDescription = expanded ? description : description.slice(0, 300) + (shouldTruncate ? '...' : '');
// Format date
const formatDate = (dateStr: string) => {
if (!dateStr || dateStr === 'Invalid Date') return '';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '';
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return '';
}
};
// Format view count
const formatViews = (views: string) => {
if (!views || views === '0') return 'No views';
const num = parseInt(views.replace(/[^0-9]/g, '') || '0');
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M views';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K views';
return num.toLocaleString() + ' views';
};
return (
<div style={{ padding: '12px 0' }}>
{/* Title */}
<h1 style={{
fontSize: '18px',
fontWeight: '600',
marginBottom: '8px',
color: 'var(--yt-text-primary)',
lineHeight: '1.3',
}}>
{video.title || 'Untitled Video'}
</h1>
{/* Channel Info & Actions Row */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px',
paddingBottom: '12px',
borderBottom: '1px solid var(--yt-border)',
}}>
{/* Channel - only show name, no avatar */}
<div style={{
color: 'var(--yt-text-primary)',
fontWeight: '500',
fontSize: '14px',
}}>
{video.channelTitle || 'Unknown Channel'}
</div>
{/* Action Buttons - Subscribe, Share, Save */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
{/* Subscribe Button with Toggle State */}
<button
onClick={handleSubscribe}
disabled={subscribing}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: subscribed ? 'var(--yt-hover)' : '#cc0000',
color: subscribed ? 'var(--yt-text-primary)' : '#fff',
border: subscribed ? '1px solid var(--yt-border)' : 'none',
borderRadius: '18px',
cursor: subscribing ? 'wait' : 'pointer',
fontWeight: '500',
fontSize: '13px',
transition: 'all 0.2s',
opacity: subscribing ? 0.7 : 1,
}}
>
{subscribing ? (
'...'
) : subscribed ? (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
Subscribed
</>
) : (
'Subscribe'
)}
</button>
{/* Share Button */}
<button
onClick={async () => {
try {
if (typeof navigator !== 'undefined' && navigator.share) {
try {
await navigator.share({
title: video.title || 'Check out this video',
url: window.location.href,
});
return;
} catch (shareErr: any) {
if (shareErr.name === 'AbortError') {
return;
}
}
}
await navigator.clipboard.writeText(window.location.href);
alert('Link copied to clipboard!');
} catch (err) {
alert('Could not share or copy link');
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: 'var(--yt-hover)',
color: 'var(--yt-text-primary)',
border: 'none',
borderRadius: '18px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
transition: 'background-color 0.2s',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9.41 15.95L12 13.36l2.59 2.59L16 14.54l-2.59-2.59L16 9.36l-1.41-1.41L12 10.54 9.41 7.95 8 9.36l2.59 2.59L8 14.54z"/>
</svg>
Share
</button>
{/* Save Button with Toggle State */}
<button
onClick={handleSave}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: isSaved ? 'var(--yt-blue)' : 'var(--yt-hover)',
color: isSaved ? '#fff' : 'var(--yt-text-primary)',
border: 'none',
borderRadius: '18px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
transition: 'all 0.2s',
}}
>
{isSaved ? (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
</svg>
Saved
</>
) : (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 10H2v2h12v-2zm0-4H2v2h12V6zm4 8v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2 16h8v-2H2v2z"/>
</svg>
Save
</>
)}
</button>
</div>
</div>
{/* Description Box */}
<div style={{
backgroundColor: 'var(--yt-hover)',
borderRadius: '12px',
padding: '12px',
marginTop: '12px',
}}>
{/* Views and Date */}
<div style={{
display: 'flex',
gap: '8px',
marginBottom: '8px',
fontSize: '13px',
fontWeight: '600',
color: 'var(--yt-text-primary)'
}}>
<span>{formatViews(video.viewCount)}</span>
{video.publishedAt && formatDate(video.publishedAt) && (
<>
<span></span>
<span>{formatDate(video.publishedAt)}</span>
</>
)}
</div>
{/* Description */}
{hasDescription ? (
<div style={{
fontSize: '13px',
color: 'var(--yt-text-primary)',
lineHeight: '1.5',
whiteSpace: 'pre-wrap',
}}>
{displayDescription}
{shouldTruncate && (
<button
onClick={() => setExpanded(!expanded)}
style={{
background: 'none',
border: 'none',
color: 'var(--yt-blue)',
cursor: 'pointer',
fontWeight: '500',
padding: 0,
marginLeft: '4px',
}}
>
{expanded ? ' Show less' : ' ...more'}
</button>
)}
</div>
) : null}
{/* Tags */}
{video.tags && video.tags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '12px' }}>
{video.tags.slice(0, 10).map((tag: string, i: number) => (
<span key={i} style={{
backgroundColor: 'var(--yt-background)',
padding: '4px 10px',
borderRadius: '14px',
fontSize: '12px',
color: 'var(--yt-blue)',
cursor: 'pointer',
}}>
{tag}
</span>
))}
</div>
)}
</div>
</div>
);
}
// Mix Playlist Component
function MixPlaylist({ videos, currentIndex, onVideoSelect, title }: {
videos: VideoData[];
currentIndex: number;
onVideoSelect: (index: number) => void;
title?: string;
}) {
return (
<div style={{
backgroundColor: 'var(--yt-hover)',
borderRadius: '12px',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
padding: '12px 16px',
borderBottom: '1px solid var(--yt-border)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<div>
<h3 style={{ fontSize: '14px', fontWeight: '600', margin: 0, color: 'var(--yt-text-primary)' }}>
{title || 'Mix Playlist'}
</h3>
<p style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', margin: '2px 0 0 0' }}>
{videos.length} videos Auto-play is on
</p>
</div>
</div>
{/* Video List */}
<div style={{ maxHeight: '360px', overflowY: 'auto' }}>
{videos.map((video, index) => (
<div
key={video.id}
onClick={() => onVideoSelect(index)}
style={{
display: 'flex',
gap: '10px',
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: index === currentIndex ? 'var(--yt-active)' : 'transparent',
borderLeft: index === currentIndex ? '3px solid var(--yt-blue)' : '3px solid transparent',
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => {
if (index !== currentIndex) {
(e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.05)';
}
}}
onMouseLeave={(e) => {
if (index !== currentIndex) {
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
}
}}
>
{/* Thumbnail with index */}
<div style={{ position: 'relative', flexShrink: 0 }}>
<img
src={video.thumbnail || `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`}
alt={video.title}
style={{
width: '100px',
height: '56px',
objectFit: 'cover',
borderRadius: '6px',
}}
onError={(e) => {
(e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/default.jpg`;
}}
/>
<div style={{
position: 'absolute',
bottom: '3px',
left: '3px',
backgroundColor: 'rgba(0,0,0,0.8)',
color: '#fff',
padding: '1px 4px',
borderRadius: '3px',
fontSize: '10px',
}}>
{index + 1}/{videos.length}
</div>
{index === currentIndex && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: 'rgba(0,0,0,0.8)',
borderRadius: '50%',
padding: '6px',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</div>
)}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '12px',
fontWeight: index === currentIndex ? '600' : '500',
color: 'var(--yt-text-primary)',
lineHeight: '1.2',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{video.title}
</div>
<div style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
{video.uploader}
</div>
{video.duration && (
<div style={{ fontSize: '10px', color: 'var(--yt-text-secondary)', marginTop: '1px' }}>
{video.duration}
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}
// Comment Section
function CommentSection({ videoId }: { videoId: string }) {
const [comments, setComments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showAll, setShowAll] = useState(false);
useEffect(() => {
const loadComments = async () => {
try {
const data = await getCommentsClient(videoId, 50);
setComments(data);
} catch (error) {
console.error('Failed to load comments:', error);
} finally {
setLoading(false);
}
};
loadComments();
}, [videoId]);
if (loading) {
return (
<div style={{ padding: '24px 0', color: 'var(--yt-text-secondary)' }}>
Loading comments...
</div>
);
}
const displayedComments = showAll ? comments : comments.slice(0, 5);
return (
<div style={{ padding: '24px 0', borderTop: '1px solid var(--yt-border)' }}>
<h2 style={{ fontSize: '16px', fontWeight: '600', marginBottom: '16px', color: 'var(--yt-text-primary)' }}>
{comments.length} Comments
</h2>
{/* Sort dropdown */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '24px' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--yt-text-secondary)">
<path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/>
</svg>
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)' }}>Sort by</span>
</div>
{/* Comments List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
{displayedComments.map((comment) => (
<div key={comment.id} style={{ display: 'flex', gap: '12px' }}>
{comment.author_thumbnail ? (
<img
src={comment.author_thumbnail}
alt={comment.author}
style={{ width: '40px', height: '40px', borderRadius: '50%', backgroundColor: 'var(--yt-hover)', flexShrink: 0 }}
/>
) : null}
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '13px', fontWeight: '500', color: 'var(--yt-text-primary)' }}>
{comment.author}
</span>
<span style={{ fontSize: '11px', color: 'var(--yt-text-secondary)' }}>
{comment.timestamp}
</span>
</div>
<div style={{ fontSize: '14px', color: 'var(--yt-text-primary)', marginTop: '4px', lineHeight: '1.5' }}>
{comment.text}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '8px' }}>
<button style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
background: 'none',
border: 'none',
cursor: 'pointer',
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"/>
</svg>
{comment.likes}
</button>
<button style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--yt-text-secondary)',
fontSize: '12px',
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2z"/>
</svg>
</button>
<button style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--yt-blue)',
fontSize: '12px',
fontWeight: '500',
}}>
Reply
</button>
</div>
</div>
</div>
))}
</div>
{comments.length > 5 && (
<button
onClick={() => setShowAll(!showAll)}
style={{
marginTop: '16px',
background: 'none',
border: 'none',
color: 'var(--yt-blue)',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
padding: '8px 0',
}}
>
{showAll ? 'Show less' : `Show all ${comments.length} comments`}
</button>
)}
</div>
);
}
export default function ClientWatchPage() {
const searchParams = useSearchParams();
const router = useRouter();
const videoId = searchParams.get('v');
const [videoInfo, setVideoInfo] = useState<any>(null);
const [relatedVideos, setRelatedVideos] = useState<VideoData[]>([]);
const [mixPlaylist, setMixPlaylist] = useState<VideoData[]>([]);
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(-1);
const [activeTab, setActiveTab] = useState<'upnext' | 'mix'>('upnext');
const [apiError, setApiError] = useState<string | null>(null);
// Scroll to top when video changes or page loads
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
}, [videoId]);
useEffect(() => {
if (!videoId) return;
const loadVideoData = async () => {
try {
setLoading(true);
setApiError(null);
// Check cache for video details
let video = getCachedData(`video_${videoId}`);
if (!video) {
video = await getVideoDetailsClient(videoId);
if (video) setCachedData(`video_${videoId}`, video);
}
setVideoInfo(video);
// Add to watch history (localStorage)
if (video) {
addToHistory({
videoId: videoId,
title: video.title,
thumbnail: video.thumbnail,
channelTitle: video.channelTitle,
});
}
// Get related videos - use channel name and video title for better results
// Even if video is null, we can still try to get related videos
const searchTerms = video?.title?.split(' ').filter((w: string) => w.length > 3).slice(0, 5).join(' ') || 'music';
const channelName = video?.channelTitle || '';
// Check cache for related videos
const cacheKey = `related_${videoId}_${searchTerms}`;
let relatedResults = getCachedData(cacheKey);
let mixResults = getCachedData(`mix_${videoId}_${searchTerms}`);
if (!relatedResults || !mixResults) {
// Optimized: Use just 2 search requests instead of 5 to save API quota
[relatedResults, mixResults] = await Promise.all([
searchVideosClient(`${channelName} ${searchTerms}`, 20),
searchVideosClient(`${searchTerms} mix compilation`, 20),
]);
if (relatedResults && relatedResults.length > 0) setCachedData(cacheKey, relatedResults);
if (mixResults && mixResults.length > 0) setCachedData(`mix_${videoId}_${searchTerms}`, mixResults);
}
// Deduplicate and filter related videos - ensure arrays
const uniqueRelated = Array.isArray(relatedResults) ? relatedResults.filter((v, index, self) =>
index === self.findIndex(item => item.id === v.id) && v.id !== videoId
) : [];
setCurrentIndex(0);
setRelatedVideos(uniqueRelated);
// Use remaining videos for mix playlist - ensure array
const uniqueMix = Array.isArray(mixResults) ? mixResults.filter((v, index, self) =>
index === self.findIndex(item => item.id === v.id) &&
v.id !== videoId &&
!uniqueRelated.some(r => r.id === v.id)
) : [];
setMixPlaylist(uniqueMix.slice(0, 20));
// Set error message if video details failed but we have related videos
if (!video) {
setApiError('Video info unavailable, but you can still browse related videos.');
}
} catch (error) {
console.error('Failed to load video data:', error);
// Fallback with fewer requests
try {
const fallbackResults = await searchVideosClient('music popular', 20);
setRelatedVideos(Array.isArray(fallbackResults) ? fallbackResults.slice(0, 10) : []);
setMixPlaylist(Array.isArray(fallbackResults) ? fallbackResults.slice(10, 20) : []);
setApiError('Unable to load video details. Showing suggested videos instead.');
} catch (e: any) {
console.error('Fallback also failed:', e);
// Set empty arrays to show user-friendly message
setRelatedVideos([]);
setMixPlaylist([]);
// Set user-friendly error message
if (e?.message?.includes('quota exceeded')) {
setApiError('YouTube API quota exceeded. Please try again later.');
} else if (e?.message?.includes('API key')) {
setApiError('API key issue. Please check configuration.');
} else {
setApiError('Unable to load related videos. Please try again.');
}
}
} finally {
setLoading(false);
}
};
loadVideoData();
}, [videoId]);
const handleVideoSelect = (index: number) => {
const video = activeTab === 'upnext' ? relatedVideos[index] : mixPlaylist[index];
if (video) {
router.push(`/watch?v=${video.id}`);
}
};
const handlePrevious = () => {
if (currentIndex > 0) {
const prevVideo = relatedVideos[currentIndex - 1];
router.push(`/watch?v=${prevVideo.id}`);
}
};
const handleNext = () => {
const playlist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
if (currentIndex < playlist.length - 1) {
const nextVideo = playlist[currentIndex + 1];
router.push(`/watch?v=${nextVideo.id}`);
}
};
const handleVideoEnd = () => {
const playlist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
if (currentIndex < playlist.length - 1) {
handleNext();
}
};
if (!videoId) {
return <div style={{ padding: '2rem', color: 'var(--yt-text-primary)' }}>No video ID provided</div>;
}
if (loading) {
return <LoadingSpinner fullScreen size="large" text="Loading video..." />;
}
const currentPlaylist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
return (
<div style={{
backgroundColor: 'var(--yt-background)',
color: 'var(--yt-text-primary)',
minHeight: '100vh',
}}>
<div className="watch-page-container" style={{
maxWidth: '1800px',
margin: '0 auto',
padding: '24px',
display: 'grid',
gridTemplateColumns: '1fr 400px',
gap: '24px',
}}>
{/* Main Content */}
<div className="watch-main">
{/* Video Player */}
<div style={{ position: 'relative', width: '100%' }}>
<YouTubePlayer
videoId={videoId}
title={videoInfo?.title}
autoplay={true}
onVideoEnd={handleVideoEnd}
/>
</div>
{/* Player Controls */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
gap: '8px',
}}>
<button
onClick={handlePrevious}
disabled={currentIndex <= 0}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: currentIndex > 0 ? 'var(--yt-hover)' : 'transparent',
color: currentIndex > 0 ? 'var(--yt-text-primary)' : 'var(--yt-text-secondary)',
border: '1px solid var(--yt-border)',
borderRadius: '18px',
cursor: currentIndex > 0 ? 'pointer' : 'not-allowed',
fontSize: '13px',
fontWeight: '500',
opacity: currentIndex > 0 ? 1 : 0.5,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
Previous
</button>
<button
onClick={handleNext}
disabled={currentIndex >= currentPlaylist.length - 1}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: currentIndex < currentPlaylist.length - 1 ? 'var(--yt-blue)' : 'var(--yt-hover)',
color: '#fff',
border: 'none',
borderRadius: '18px',
cursor: currentIndex < currentPlaylist.length - 1 ? 'pointer' : 'not-allowed',
fontSize: '13px',
fontWeight: '500',
}}
>
Next
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
</div>
{/* Video Info */}
<VideoInfo video={videoInfo} />
{/* Comments */}
<CommentSection videoId={videoId} />
</div>
{/* Sidebar */}
<div className="watch-sidebar" style={{
position: 'sticky',
top: '70px',
height: 'fit-content',
maxHeight: 'calc(100vh - 80px)',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}>
{/* Mix Playlist - Always on top */}
<MixPlaylist
videos={mixPlaylist}
currentIndex={currentIndex}
onVideoSelect={handleVideoSelect}
title={videoInfo?.title ? `Mix - ${videoInfo.title.split(' ').slice(0, 3).join(' ')}` : 'Mix Playlist'}
/>
{/* API Error Message */}
{apiError && (
<div style={{
padding: '10px',
backgroundColor: 'rgba(255, 0, 0, 0.1)',
border: '1px solid rgba(255, 0, 0, 0.2)',
borderRadius: '8px',
color: 'var(--yt-text-secondary)',
fontSize: '12px',
textAlign: 'center',
}}>
{apiError}
</div>
)}
{/* Up Next Section */}
<div style={{
backgroundColor: 'var(--yt-hover)',
borderRadius: '12px',
overflow: 'hidden',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
borderBottom: '1px solid var(--yt-border)',
}}>
<h3 style={{ fontSize: '14px', fontWeight: '600', margin: 0, color: 'var(--yt-text-primary)' }}>
Up Next
</h3>
<span style={{ fontSize: '11px', color: 'var(--yt-text-secondary)' }}>
{relatedVideos.length} videos
</span>
</div>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{relatedVideos.slice(0, 8).map((video, index) => (
<div
key={video.id}
onClick={() => handleVideoSelect(index)}
style={{
display: 'flex',
gap: '10px',
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: index === currentIndex ? 'var(--yt-active)' : 'transparent',
borderLeft: index === currentIndex ? '3px solid var(--yt-blue)' : '3px solid transparent',
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => {
if (index !== currentIndex) {
(e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.05)';
}
}}
onMouseLeave={(e) => {
if (index !== currentIndex) {
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
}
}}
>
<div style={{ position: 'relative', flexShrink: 0 }}>
<img
src={video.thumbnail || `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`}
alt={video.title}
style={{ width: '120px', height: '68px', objectFit: 'cover', borderRadius: '6px' }}
onError={(e) => {
(e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`;
}}
/>
{video.duration && (
<div style={{
position: 'absolute',
bottom: '3px',
right: '3px',
backgroundColor: 'rgba(0,0,0,0.8)',
color: '#fff',
padding: '1px 4px',
borderRadius: '3px',
fontSize: '10px',
}}>
{video.duration}
</div>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '12px',
fontWeight: '500',
color: 'var(--yt-text-primary)',
lineHeight: '1.2',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{video.title}
</div>
<div style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
{video.uploader}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Responsive styles */}
<style jsx>{`
@media (max-width: 1024px) {
.watch-page-container {
grid-template-columns: 1fr !important;
}
.watch-sidebar {
position: relative !important;
top: 0 !important;
max-height: none !important;
}
}
@media (max-width: 768px) {
.watch-page-container {
padding: 12px !important;
}
}
`}</style>
</div>
);
}