-
-
- Share
-
-
-
- {showFormats && (
-
setShowFormats(false)}
- />
- )}
-
-
- {isDownloading ? (
-
- {progressPercent > 0 ? `${progressPercent}%` : ''}
-
-
- ) : 'Download'}
-
-
- {showFormats && (
-
-
- Select Quality
- setShowFormats(false)}
- style={{
- background: 'none',
- border: 'none',
- color: 'var(--yt-text-secondary)',
- fontSize: '20px',
- cursor: 'pointer',
- padding: '4px 8px'
- }}
- >
- ×
-
-
-
- {isLoadingFormats ? (
-
-
- Loading...
-
- ) : formats.length === 0 ? (
-
- No formats available
-
- ) : (
- formats.map(f => {
- const height = parseInt(f.resolution) || 0;
- const badge = getQualityBadge(height);
-
- return (
-
handleDownload(f)}
- className="format-item-hover"
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- width: '100%',
- padding: '12px 16px',
- backgroundColor: 'transparent',
- border: 'none',
- color: 'var(--yt-text-primary)',
- cursor: 'pointer',
- fontSize: '14px',
- transition: 'background-color 0.15s',
- }}
- >
-
- {badge && (
-
- {badge.label}
-
- )}
- {getQualityLabel(f.resolution)}
-
-
- {formatFileSize(f.filesize) || 'Unknown size'}
-
-
- );
- })
- )}
-
- )}
-
-
- {isDownloading && downloadProgress && (
-
- {downloadProgress}
-
- )}
-
- );
-}
diff --git a/frontend/app/watch/WatchFeed.tsx b/frontend/app/watch/WatchFeed.tsx
deleted file mode 100644
index 19e751d..0000000
--- a/frontend/app/watch/WatchFeed.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import InfiniteVideoGrid from '../components/InfiniteVideoGrid';
-import { VideoData } from '../constants';
-
-interface Props {
- videoId: string;
- regionLabel: string;
- initialMix: VideoData[]; // Initial 40:40:20 mix data for "All" tab
- initialRelated: VideoData[]; // Initial related data for "Related" tab
- initialSuggestions: VideoData[]; // Initial suggestions data for "For You" tab
-}
-
-const WATCH_TABS = ['All', 'Mix', 'Related', 'For You', 'Trending'];
-
-export default function WatchFeed({ videoId, regionLabel, initialMix, initialRelated, initialSuggestions }: Props) {
- const [activeTab, setActiveTab] = useState('All');
-
- // Determine category id and initial videos based on active tab
- let currentCategory = 'WatchAll';
- let videos = initialMix;
-
- if (activeTab === 'Related') {
- currentCategory = 'WatchRelated';
- videos = initialRelated;
- } else if (activeTab === 'For You') {
- currentCategory = 'WatchForYou';
- videos = initialSuggestions;
- } else if (activeTab === 'Trending') {
- currentCategory = 'Trending';
- // 'Trending' falls back to standard fetchMoreVideos logic which handles normal categories or we can handle it specifically.
- // It's empty initially if missing, the infinite grid will load it.
- videos = [];
- }
-
- return (
-
-
- {WATCH_TABS.map((tab) => {
- const isActive = tab === activeTab;
- return (
- {
- if (tab === 'Mix') {
- window.location.href = `/watch?v=${videoId}&list=RD${videoId}`;
- } else {
- setActiveTab(tab);
- }
- }}
- className={`chip ${isActive ? 'active' : ''}`}
- style={{
- fontSize: '14px',
- whiteSpace: 'nowrap',
- backgroundColor: isActive ? 'var(--foreground)' : 'var(--yt-hover)',
- color: isActive ? 'var(--background)' : 'var(--yt-text-primary)'
- }}
- >
- {tab}
-
- );
- })}
-
-
-
-
-
-
- );
-}
diff --git a/frontend/app/watch/YouTubePlayer.tsx b/frontend/app/watch/YouTubePlayer.tsx
new file mode 100644
index 0000000..7592b6d
--- /dev/null
+++ b/frontend/app/watch/YouTubePlayer.tsx
@@ -0,0 +1,239 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import LoadingSpinner from '../components/LoadingSpinner';
+
+declare global {
+ interface Window {
+ YT: any;
+ onYouTubeIframeAPIReady: () => void;
+ }
+}
+
+interface YouTubePlayerProps {
+ videoId: string;
+ title?: string;
+ autoplay?: boolean;
+ onVideoEnd?: () => void;
+ onVideoReady?: () => void;
+}
+
+function PlayerSkeleton() {
+ return (
+
+
+
+ );
+}
+
+export default function YouTubePlayer({
+ videoId,
+ title,
+ autoplay = true,
+ onVideoEnd,
+ onVideoReady
+}: YouTubePlayerProps) {
+ const playerRef = useRef
(null);
+ const playerInstanceRef = useRef(null);
+ const [isApiReady, setIsApiReady] = useState(false);
+ const [isPlayerReady, setIsPlayerReady] = useState(false);
+ const [error, setError] = useState(null);
+ const router = useRouter();
+
+ // Load YouTube IFrame API
+ useEffect(() => {
+ if (window.YT && window.YT.Player) {
+ setIsApiReady(true);
+ return;
+ }
+
+ // Check if script already exists
+ const existingScript = document.querySelector('script[src*="youtube.com/iframe_api"]');
+ if (existingScript) {
+ // Script exists, wait for it to load
+ const checkYT = setInterval(() => {
+ if (window.YT && window.YT.Player) {
+ setIsApiReady(true);
+ clearInterval(checkYT);
+ }
+ }, 100);
+ return () => clearInterval(checkYT);
+ }
+
+ const tag = document.createElement('script');
+ tag.src = 'https://www.youtube.com/iframe_api';
+ tag.async = true;
+ document.head.appendChild(tag);
+
+ window.onYouTubeIframeAPIReady = () => {
+ console.log('YouTube IFrame API ready');
+ setIsApiReady(true);
+ };
+
+ return () => {
+ // Clean up
+ window.onYouTubeIframeAPIReady = () => {};
+ };
+ }, []);
+
+ // Initialize player when API is ready
+ useEffect(() => {
+ if (!isApiReady || !playerRef.current || !videoId) return;
+
+ // Destroy previous player instance if exists
+ if (playerInstanceRef.current) {
+ try {
+ playerInstanceRef.current.destroy();
+ } catch (e) {
+ console.log('Error destroying player:', e);
+ }
+ playerInstanceRef.current = null;
+ }
+
+ try {
+ const player = new window.YT.Player(playerRef.current, {
+ videoId: videoId,
+ playerVars: {
+ autoplay: autoplay ? 1 : 0,
+ controls: 1,
+ rel: 0,
+ modestbranding: 0,
+ playsinline: 1,
+ enablejsapi: 1,
+ origin: window.location.origin,
+ widget_referrer: window.location.href,
+ iv_load_policy: 3,
+ fs: 0,
+ disablekb: 0,
+ color: 'white',
+ },
+ events: {
+ onReady: (event: any) => {
+ console.log('YouTube Player ready for video:', videoId);
+ setIsPlayerReady(true);
+ if (onVideoReady) onVideoReady();
+
+ // Auto-play if enabled
+ if (autoplay) {
+ try {
+ event.target.playVideo();
+ } catch (e) {
+ console.log('Autoplay prevented:', e);
+ }
+ }
+ },
+ onStateChange: (event: any) => {
+ // Video ended
+ if (event.data === window.YT.PlayerState.ENDED) {
+ if (onVideoEnd) {
+ onVideoEnd();
+ }
+ }
+ },
+ onError: (event: any) => {
+ console.error('YouTube Player Error:', event.data);
+ setError(`Failed to load video (Error ${event.data})`);
+ },
+ },
+ });
+
+ playerInstanceRef.current = player;
+ } catch (error) {
+ console.error('Failed to create YouTube player:', error);
+ setError('Failed to initialize video player');
+ }
+
+ return () => {
+ if (playerInstanceRef.current) {
+ try {
+ playerInstanceRef.current.destroy();
+ } catch (e) {
+ console.log('Error cleaning up player:', e);
+ }
+ playerInstanceRef.current = null;
+ }
+ };
+ }, [isApiReady, videoId, autoplay]);
+
+ // Handle video end
+ useEffect(() => {
+ if (!isPlayerReady || !onVideoEnd) return;
+
+ const handleVideoEnd = () => {
+ onVideoEnd();
+ };
+
+ // The onStateChange event handler already handles this
+ }, [isPlayerReady, onVideoEnd]);
+
+ if (error) {
+ return (
+
+
{error}
+
window.open(`https://www.youtube.com/watch?v=${videoId}`, '_blank')}
+ style={{
+ padding: '8px 16px',
+ backgroundColor: '#ff0000',
+ color: '#fff',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ }}
+ >
+ Watch on YouTube
+
+
+ );
+ }
+
+ return (
+
+ {!isPlayerReady && !error &&
}
+
+
+ );
+}
+
+// Utility function to play a video
+export function playVideo(videoId: string) {
+ if (window.YT && window.YT.Player) {
+ // Could create a new player instance or use existing one
+ console.log('Playing video:', videoId);
+ }
+}
+
+// Utility function to pause video
+export function pauseVideo() {
+ // Would need to reference player instance
+}
\ No newline at end of file
diff --git a/frontend/app/watch/page.tsx b/frontend/app/watch/page.tsx
index e56fed7..254f7e5 100644
--- a/frontend/app/watch/page.tsx
+++ b/frontend/app/watch/page.tsx
@@ -1,272 +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 NextVideoClient from './NextVideoClient';
-import WatchFeed from './WatchFeed';
-import PlaylistPanel from './PlaylistPanel';
-import Comments from './Comments';
-import { API_BASE, VideoData } from '../constants';
-import { cookies } from 'next/headers';
-import { getRelatedVideos, getSuggestedVideos, getSearchVideos } from '../actions';
-import { addRegion, getRandomModifier } from '../utils';
-
-const REGION_LABELS: Record = {
- VN: 'Vietnam',
- US: 'United States',
- JP: 'Japan',
- KR: 'South Korea',
- IN: 'India',
- GB: 'United Kingdom',
- GLOBAL: '',
-};
-
-interface VideoInfo {
- title: string;
- description: string;
- uploader: string;
- channel_id: string;
- view_count: number;
- thumbnail?: string;
-}
-
-async function getVideoInfo(id: string): Promise {
- try {
- const res = await fetch(`${API_BASE}/api/get_stream_info?v=${id}`, { cache: 'no-store' });
- if (!res.ok) return null;
- const data = await res.json();
- return {
- title: data.title || `Video ${id}`,
- description: data.description || '',
- uploader: data.uploader || 'Unknown',
- channel_id: data.channel_id || '',
- view_count: data.view_count || 0,
- thumbnail: data.thumbnail || `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`,
- };
- } catch (e) {
- console.error(e);
- return null;
- }
-}
-
-function formatNumber(num: number): string {
- if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
- if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
- return num.toString();
-}
-
-export default async function WatchPage({
- searchParams,
-}: {
- searchParams: Promise<{ [key: string]: string | string[] | undefined }>
-}) {
- const awaitParams = await searchParams;
- const v = awaitParams.v as string;
- const list = awaitParams.list as string;
- const isMix = list?.startsWith('RD');
-
- if (!v) {
- return No video ID provided
;
- }
-
- const info = await getVideoInfo(v);
-
- const cookieStore = await cookies();
- const regionCode = cookieStore.get('region')?.value || 'VN';
- const regionLabel = REGION_LABELS[regionCode] || '';
- const randomMod = getRandomModifier();
-
- // Fetch initial mix
- const promises = [
- getSuggestedVideos(12),
- getRelatedVideos(v, 12),
- getSearchVideos(addRegion("trending", regionLabel) + ' ' + randomMod, 6)
- ];
-
- const [suggestedRes, relatedRes, trendingRes] = await Promise.all(promises);
-
- const interleavedList: VideoData[] = [];
- const seenIds = new Set();
-
- let sIdx = 0, rIdx = 0, tIdx = 0;
- while (sIdx < suggestedRes.length || rIdx < relatedRes.length || tIdx < trendingRes.length) {
- for (let i = 0; i < 2 && sIdx < suggestedRes.length; i++) {
- const vid = suggestedRes[sIdx++];
- if (!seenIds.has(vid.id) && vid.id !== v) { interleavedList.push(vid); seenIds.add(vid.id); }
- }
- for (let i = 0; i < 2 && rIdx < relatedRes.length; i++) {
- const vid = relatedRes[rIdx++];
- if (!seenIds.has(vid.id) && vid.id !== v) { interleavedList.push(vid); seenIds.add(vid.id); }
- }
- for (let i = 0; i < 1 && tIdx < trendingRes.length; i++) {
- const vid = trendingRes[tIdx++];
- if (!seenIds.has(vid.id) && vid.id !== v) { interleavedList.push(vid); seenIds.add(vid.id); }
- }
- }
-
- let initialMix = interleavedList;
- const initialRelated = relatedRes.filter(vid => vid.id !== v);
- const initialSuggestions = suggestedRes.filter(vid => vid.id !== v);
-
- // If not currently inside a mix, inject a Mix Card at the start
- if (!isMix && info) {
- const mixCard: VideoData = {
- id: v,
- title: `Mix - ${info.uploader || 'Auto-generated'}`,
- uploader: info.uploader || 'KV-Tube',
- thumbnail: info.thumbnail || `https://i.ytimg.com/vi/${v}/maxresdefault.jpg`,
- view_count: 0,
- duration: '50+',
- list_id: `RD${v}`,
- is_mix: true
- };
- initialMix.unshift(mixCard);
- }
-
- // Always build a Mix playlist
- let playlistVideos: VideoData[] = [];
- const mixBaseId = isMix ? list.replace('RD', '') : v;
- const mixListId = isMix ? list : `RD${v}`;
- {
- const baseInfo = isMix ? await getVideoInfo(mixBaseId) : info;
-
- // Seed the playlist with the base video
- const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
- if (baseInfo) {
- playlistVideos.push({
- id: mixBaseId,
- title: baseInfo.title,
- uploader: baseInfo.uploader,
- thumbnail: baseInfo.thumbnail || `https://i.ytimg.com/vi/${mixBaseId}/hqdefault.jpg` || DEFAULT_THUMBNAIL,
- view_count: baseInfo.view_count,
- duration: ''
- });
- }
-
- // Multi-source search to build a rich playlist (15-25 videos)
- const uploaderName = baseInfo?.uploader || '';
- const videoTitle = baseInfo?.title || '';
- const titleKeywords = videoTitle.split(/[\s\-|]+/).filter((w: string) => w.length > 2).slice(0, 4).join(' ');
-
- const mixPromises = [
- uploaderName ? getSearchVideos(uploaderName, 20) : Promise.resolve([]),
- titleKeywords ? getSearchVideos(titleKeywords, 20) : Promise.resolve([]),
- getRelatedVideos(mixBaseId, 20),
- ];
-
- const [byUploader, byTitle, byRelated] = await Promise.all(mixPromises);
- const seenMixIds = new Set(playlistVideos.map(p => p.id));
-
- const sources = [byUploader, byTitle, byRelated];
- let added = 0;
- const maxPlaylist = 50;
-
- let idx = [0, 0, 0];
- while (added < maxPlaylist) {
- let anyAdded = false;
- for (let s = 0; s < sources.length; s++) {
- while (idx[s] < sources[s].length && added < maxPlaylist) {
- const vid = sources[s][idx[s]++];
- if (!seenMixIds.has(vid.id)) {
- seenMixIds.add(vid.id);
- playlistVideos.push({
- ...vid,
- thumbnail: vid.thumbnail || `https://i.ytimg.com/vi/${vid.id}/hqdefault.jpg` || DEFAULT_THUMBNAIL
- });
- added++;
- anyAdded = true;
- break;
- }
- }
- }
- if (!anyAdded) break;
- }
- }
-
- // Determine the next video
- let nextVideoId = '';
- let nextListId: string | undefined = undefined;
-
- if (playlistVideos.length > 0) {
- const currentIndex = playlistVideos.findIndex(p => p.id === v);
- if (currentIndex >= 0 && currentIndex < playlistVideos.length - 1) {
- nextVideoId = playlistVideos[currentIndex + 1].id;
- nextListId = mixListId;
- } else {
- nextVideoId = initialMix.length > 0 && initialMix[0].is_mix ? (initialMix[1]?.id || '') : (initialMix[0]?.id || '');
- }
- } else {
- const firstRealVideo = initialMix.find(vid => !vid.is_mix);
- nextVideoId = firstRealVideo ? firstRealVideo.id : '';
- }
+import ClientWatchPage from './ClientWatchPage';
+import LoadingSpinner from '../components/LoadingSpinner';
+export default function WatchPage() {
return (
-
- {nextVideoId &&
}
-
-
-
-
-
-
- {info?.title || `Video ${v}`}
-
-
- {info && (
-
-
-
-
- {info.uploader}
-
-
-
-
-
-
-
-
-
- )}
-
- {info && (
-
-
- {formatNumber(info.view_count)} views
-
-
- {info.description || 'No description available.'}
-
-
- )}
-
-
- {/* Comments as a separate flex child for responsive reordering */}
-
-
-
-
-
- {playlistVideos.length > 0 && (
-
- )}
-
-
-
+ }>
+
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
index c9ed483..6da0907 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,18 +9,12 @@
"lint": "eslint"
},
"dependencies": {
- "@clappr/core": "^0.13.2",
- "@clappr/player": "^0.11.16",
"@fontsource/roboto": "^5.2.9",
- "@vidstack/react": "^1.12.13",
- "artplayer": "^5.3.0",
- "clappr": "^0.3.13",
"hls.js": "^1.6.15",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
- "react-icons": "^5.5.0",
- "vidstack": "^1.12.13"
+ "react-icons": "^5.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/frontend/tmp/check_main_content.js b/frontend/tmp/check_main_content.js
deleted file mode 100644
index 040d9ba..0000000
--- a/frontend/tmp/check_main_content.js
+++ /dev/null
@@ -1 +0,0 @@
-console.log(document.querySelector(".yt-main-content").style.marginLeft); console.log(window.getComputedStyle(document.querySelector(".yt-main-content")).marginLeft);
diff --git a/page.html b/page.html
deleted file mode 100644
index e536c0f..0000000
--- a/page.html
+++ /dev/null
@@ -1,8 +0,0 @@
-KV-Tube
Home Shorts Subscriptions You
\ No newline at end of file