kv-tube/frontend/app/shorts/page.tsx

510 lines
18 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { IoHeart, IoHeartOutline, IoChatbubbleOutline, IoShareOutline, IoEllipsisHorizontal, IoMusicalNote, IoRefresh, IoPlay, IoVolumeMute, IoVolumeHigh } from 'react-icons/io5';
declare global {
interface Window {
Hls: any;
}
}
interface ShortVideo {
id: string;
title: string;
uploader: string;
thumbnail: string;
view_count: number;
duration?: string;
}
interface StreamInfo {
stream_url: string;
error?: string;
}
const SHORTS_QUERIES = ['#shorts', 'youtube shorts viral', 'tiktok short', 'shorts funny', 'shorts music'];
const RANDOM_MODIFIERS = ['viral', 'popular', 'new', 'best', 'trending', 'hot', 'fresh', '2025'];
function getRandomModifier(): string {
return RANDOM_MODIFIERS[Math.floor(Math.random() * RANDOM_MODIFIERS.length)];
}
function parseDuration(duration: string): number {
if (!duration) return 0;
const parts = duration.split(':').map(Number);
if (parts.length === 2) return parts[0] * 60 + parts[1];
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
}
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();
}
async function fetchShorts(page: number): Promise<ShortVideo[]> {
try {
const query = SHORTS_QUERIES[page % SHORTS_QUERIES.length] + ' ' + getRandomModifier();
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=20`, { cache: 'no-store' });
if (!res.ok) return [];
const data = await res.json();
return data.filter((v: ShortVideo) => parseDuration(v.duration || '') <= 90);
} catch {
return [];
}
}
function ShortCard({ video, isActive }: { video: ShortVideo; isActive: boolean }) {
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(Math.floor(Math.random() * 50000) + 1000);
const [commentCount] = useState(Math.floor(Math.random() * 1000) + 50);
const [muted, setMuted] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [useFallback, setUseFallback] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<any>(null);
const [showControls, setShowControls] = useState(false);
useEffect(() => {
if (!isActive) {
if (videoRef.current) {
videoRef.current.pause();
}
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
return;
}
if (useFallback) return;
const loadStream = async () => {
setLoading(true);
setError(false);
try {
const res = await fetch(`/api/get_stream_info?v=${video.id}`);
const data: StreamInfo = await res.json();
if (data.error || !data.stream_url) {
throw new Error(data.error || 'No stream URL');
}
const videoEl = videoRef.current;
if (!videoEl) return;
const streamUrl = data.stream_url;
const isHLS = streamUrl.includes('.m3u8') || streamUrl.includes('manifest');
if (isHLS && window.Hls && window.Hls.isSupported()) {
if (hlsRef.current) {
hlsRef.current.destroy();
}
const hls = new window.Hls({
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
});
hlsRef.current = hls;
hls.loadSource(streamUrl);
hls.attachMedia(videoEl);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
setLoading(false);
videoEl.muted = muted;
videoEl.play().catch(() => {});
});
hls.on(window.Hls.Events.ERROR, () => {
setError(true);
setUseFallback(true);
});
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.src = streamUrl;
videoEl.muted = muted;
videoEl.addEventListener('loadedmetadata', () => {
setLoading(false);
videoEl.play().catch(() => {});
}, { once: true });
} else {
videoEl.src = streamUrl;
videoEl.muted = muted;
videoEl.addEventListener('loadeddata', () => {
setLoading(false);
videoEl.play().catch(() => {});
}, { once: true });
}
} catch (err) {
console.error('Stream load error:', err);
setError(true);
setUseFallback(true);
}
};
const timeout = setTimeout(() => {
if (window.Hls) {
loadStream();
} else {
const checkHls = setInterval(() => {
if (window.Hls) {
clearInterval(checkHls);
loadStream();
}
}, 100);
setTimeout(() => {
clearInterval(checkHls);
if (!window.Hls) {
setUseFallback(true);
}
}, 3000);
}
}, 100);
return () => {
clearTimeout(timeout);
};
}, [isActive, video.id, useFallback, muted]);
const toggleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !videoRef.current.muted;
setMuted(videoRef.current.muted);
}
};
const handleShare = async () => {
try {
if (navigator.share) {
await navigator.share({
title: video.title,
url: `${window.location.origin}/watch?v=${video.id}`,
});
} else {
await navigator.clipboard.writeText(`${window.location.origin}/watch?v=${video.id}`);
}
} catch {}
};
const handleRetry = () => {
setUseFallback(false);
setError(false);
setLoading(false);
};
return (
<div
style={cardWrapperStyle}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
>
<div style={cardContainerStyle}>
{useFallback ? (
<iframe
src={isActive ? `https://www.youtube.com/embed/${video.id}?autoplay=1&loop=1&playlist=${video.id}&mute=${muted ? 1 : 0}&rel=0&modestbranding=1&playsinline=1&controls=1` : ''}
style={iframeStyle}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={video.title}
/>
) : (
<>
<video
ref={videoRef}
style={videoStyle}
loop
playsInline
poster={video.thumbnail}
onClick={() => videoRef.current?.paused ? videoRef.current?.play() : videoRef.current?.pause()}
/>
{loading && (
<div style={loadingOverlayStyle}>
<div style={spinnerStyle}></div>
</div>
)}
{error && !useFallback && (
<div style={errorOverlayStyle}>
<button onClick={handleRetry} style={retryBtnStyle}>
Retry
</button>
<button onClick={() => setUseFallback(true)} style={retryBtnStyle}>
YouTube Player
</button>
</div>
)}
</>
)}
<div style={gradientStyle} />
<div style={infoStyle}>
<div style={channelStyle}>
<div style={avatarStyle}>{video.uploader?.[0]?.toUpperCase() || '?'}</div>
<span style={{ fontWeight: '600', fontSize: '13px' }}>@{video.uploader || 'Unknown'}</span>
</div>
<p style={titleStyle}>{video.title}</p>
<div style={musicStyle}><IoMusicalNote size={12} /><span>Original Sound</span></div>
</div>
<div style={actionsStyle}>
<button onClick={() => { setLiked(!liked); setLikeCount(p => liked ? p - 1 : p + 1); }} style={actionBtnStyle}>
{liked ? <IoHeart size={26} color="#ff0050" /> : <IoHeartOutline size={26} />}
<span style={actionLabelStyle}>{formatViews(likeCount)}</span>
</button>
<button style={actionBtnStyle}>
<IoChatbubbleOutline size={24} />
<span style={actionLabelStyle}>{formatViews(commentCount)}</span>
</button>
<button onClick={handleShare} style={actionBtnStyle}>
<IoShareOutline size={24} />
<span style={actionLabelStyle}>Share</span>
</button>
<button onClick={toggleMute} style={actionBtnStyle}>
{muted ? <IoVolumeMute size={24} /> : <IoVolumeHigh size={24} />}
<span style={actionLabelStyle}>{muted ? 'Unmute' : 'Mute'}</span>
</button>
<a
href={`https://www.youtube.com/watch?v=${video.id}`}
target="_blank"
rel="noopener noreferrer"
style={actionBtnStyle}
>
<IoEllipsisHorizontal size={22} />
</a>
</div>
{showControls && (
<a
href={`https://www.youtube.com/watch?v=${video.id}`}
target="_blank"
rel="noopener noreferrer"
style={openBtnStyle}
>
Open
</a>
)}
</div>
</div>
);
}
const cardWrapperStyle: React.CSSProperties = {
position: 'relative',
width: '100%',
height: '100%',
scrollSnapAlign: 'start',
scrollSnapStop: 'always',
background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const cardContainerStyle: React.CSSProperties = {
position: 'relative',
width: '100%',
maxWidth: '400px',
height: '100%',
maxHeight: 'calc(100vh - 120px)',
borderRadius: '12px',
overflow: 'hidden',
background: '#0f0f0f',
};
const videoStyle: React.CSSProperties = {
width: '100%',
height: '100%',
objectFit: 'cover',
background: '#000',
cursor: 'pointer',
};
const iframeStyle: React.CSSProperties = { width: '100%', height: '100%', border: 'none' };
const loadingOverlayStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
};
const errorOverlayStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
background: 'rgba(0,0,0,0.8)',
};
const retryBtnStyle: React.CSSProperties = {
padding: '8px 16px',
background: '#ff0050',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
};
const gradientStyle: React.CSSProperties = {
position: 'absolute', bottom: 0, left: 0, right: 0, height: '50%',
background: 'linear-gradient(transparent, rgba(0,0,0,0.85))', pointerEvents: 'none',
};
const infoStyle: React.CSSProperties = {
position: 'absolute', bottom: '16px', left: '16px', right: '70px', color: '#fff', pointerEvents: 'none',
};
const channelStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' };
const avatarStyle: React.CSSProperties = {
width: '32px', height: '32px', borderRadius: '50%',
background: 'linear-gradient(135deg, #ff0050, #ff4081)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '13px', fontWeight: '700', color: '#fff', flexShrink: 0,
};
const titleStyle: React.CSSProperties = {
fontSize: '13px', lineHeight: '18px', margin: '0 0 6px 0',
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
};
const musicStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', opacity: 0.7 };
const actionsStyle: React.CSSProperties = {
position: 'absolute', right: '10px', bottom: '80px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '16px',
};
const actionBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', color: '#fff', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px',
};
const actionLabelStyle: React.CSSProperties = { fontSize: '10px', fontWeight: '500' };
const openBtnStyle: React.CSSProperties = {
position: 'absolute',
top: '10px',
right: '10px',
padding: '6px 10px',
background: 'rgba(0,0,0,0.8)',
color: '#fff',
borderRadius: '4px',
textDecoration: 'none',
fontSize: '11px',
zIndex: 10,
};
const spinnerStyle: React.CSSProperties = {
width: '40px',
height: '40px',
border: '3px solid #333',
borderTopColor: '#ff0050',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
};
export default function ShortsPage() {
const [shorts, setShorts] = useState<ShortVideo[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(0);
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
script.async = true;
if (!document.querySelector('script[src*="hls.js"]')) {
document.head.appendChild(script);
}
}, []);
useEffect(() => { activeRef.current = activeIndex; }, [activeIndex]);
useEffect(() => { fetchShorts(0).then(d => { setShorts(d); setLoading(false); }); }, []);
useEffect(() => {
const c = containerRef.current;
if (!c || !shorts.length) return;
const onScroll = () => {
const idx = Math.round(c.scrollTop / c.clientHeight);
if (idx !== activeRef.current && idx >= 0 && idx < shorts.length) setActiveIndex(idx);
};
c.addEventListener('scroll', onScroll, { passive: true });
return () => c.removeEventListener('scroll', onScroll);
}, [shorts.length]);
useEffect(() => {
if (activeIndex >= shorts.length - 2 && !loadingMore) {
setLoadingMore(true);
fetchShorts(page + 1).then(d => {
if (d.length) {
const exist = new Set(shorts.map(v => v.id));
setShorts(p => [...p, ...d.filter(v => !exist.has(v.id))]);
setPage(p => p + 1);
}
setLoadingMore(false);
});
}
}, [activeIndex, shorts.length, loadingMore, page]);
const refresh = () => { setLoading(true); setPage(0); setActiveIndex(0); fetchShorts(0).then(d => { setShorts(d); setLoading(false); }); };
if (loading) return (
<div style={pageStyle}>
<div style={{ ...spinnerContainerStyle, width: '300px', height: '500px' }}>
<div style={spinnerStyle}></div>
</div>
</div>
);
if (!shorts.length) return (
<div style={{ ...pageStyle, color: '#fff' }}>
<div style={{ textAlign: 'center' }}>
<p style={{ marginBottom: '16px' }}>No shorts found</p>
<button onClick={refresh} style={{ padding: '10px 20px', background: '#ff0050', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', margin: '0 auto' }}>
<IoRefresh size={18} /> Refresh
</button>
</div>
</div>
);
return (
<div ref={containerRef} style={scrollContainerStyle}>
<style>{hideScrollbarCss}</style>
<style>{spinCss}</style>
{shorts.map((v, i) => <ShortCard key={v.id} video={v} isActive={i === activeIndex} />)}
{loadingMore && (
<div style={{ ...pageStyle, height: '100vh' }}>
<div style={spinnerStyle}></div>
</div>
)}
</div>
);
}
const pageStyle: React.CSSProperties = { height: 'calc(100vh - 56px)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f0f0f' };
const scrollContainerStyle: React.CSSProperties = { height: 'calc(100vh - 56px)', overflowY: 'scroll', scrollSnapType: 'y mandatory', background: '#0f0f0f', scrollbarWidth: 'none' };
const spinnerContainerStyle: React.CSSProperties = { borderRadius: '12px', background: 'linear-gradient(180deg, #1a1a1a 0%, #0f0f0f 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' };
const spinCss = '@keyframes spin { to { transform: rotate(360deg); } }';
const hideScrollbarCss = 'div::-webkit-scrollbar { display: none; }';