feat: Update subscriptions, comments, thumbnails and video player
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions

- Add categorized subscriptions page with Show more/less
- Fix comments display on watch page
- Add thumbnail fallback handling across all pages
- Increase video buffer for smoother playback
- Add visibility change handler for background play
- Update Docker config for v5 deployment
This commit is contained in:
KV-Tube Deployer 2026-03-25 07:44:48 +07:00
parent 82a51b7ee4
commit 468b2b08fc
13 changed files with 599 additions and 377 deletions

View file

@ -1,16 +0,0 @@
.venv/
.venv_clean/
env/
__pycache__/
.git/
.DS_Store
*.pyc
*.pyo
*.pyd
.idea/
.vscode/
videos/
data/
venv/
.gemini/
tmp*/

View file

@ -21,16 +21,44 @@ A modern, fast, and fully-featured YouTube-like video streaming platform. Built
- **Process Management**: `supervisord` manages the concurrent execution of the backend API and Next.js frontend within the same network namespace.
- **Data storage**: SQLite is used for watch history, optimized for `linux/amd64`.
## Deployment on Synology NAS (v4.0.7)
## Docker Deployment (v5)
### Quick Start
1. Clone or download this repository
2. Create a `data` folder in the project directory
3. Run the container:
```bash
docker-compose up -d
```
### Building the Image
To build the image locally:
```bash
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 .
```
To build and push to your registry:
```bash
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 .
docker push git.khoavo.myds.me/vndangkhoa/kv-tube:v5
```
## Deployment on Synology NAS
We recommend using **Container Manager** (DSM 7.2+) or **Docker** (DSM 6/7.1) for a robust and easily manageable deployment.
### 1. Prerequisites
- **Container Manager** or **Docker** package installed from Package Center.
- Ensure port `5011` is available on your NAS.
- Ensure ports `5011` (frontend) and `8080` (backend API) are available on your NAS.
- Create a folder named `kv-tube` in your `docker` shared folder (e.g., `/volume1/docker/kv-tube`).
### 2. Using Container Manager (Recommended)
1. Open **Container Manager** > **Project** > **Create**.
2. Set a Project Name (e.g., `kv-tube`).
3. Set Path to `/volume1/docker/kv-tube`.
@ -41,27 +69,61 @@ version: '3.8'
services:
kv-tube:
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v4.0.7
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v5
container_name: kv-tube
platform: linux/amd64
restart: unless-stopped
ports:
- "5011:3000"
- "8080:8080"
volumes:
- ./data:/app/data
environment:
- KVTUBE_DATA_DIR=/app/data
- GIN_MODE=release
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
```
5. Click **Next** until the end and **Done**. The container will build and start automatically.
### 3. Accessing the App
The application will be accessible at:
- `http://<your-nas-ip>:5011`
- **Frontend**: `http://<your-nas-ip>:5011`
- **Backend API**: `http://<your-nas-ip>:8080`
- **Mobile Users**: Add to Home Screen via Safari for the full PWA experience with background playback.
### 4. Volume Permissions (If Needed)
If you encounter permission issues with the data folder, SSH into your NAS and run:
```bash
# Create the data folder with proper permissions
sudo mkdir -p /volume1/docker/kv-tube/data
sudo chmod 755 /volume1/docker/kv-tube/data
```
### 5. Updating the Container
To update to a new version:
```bash
# Pull the latest image
docker pull git.khoavo.myds.me/vndangkhoa/kv-tube:v5
# Restart the container
docker-compose down && docker-compose up -d
```
Or use Container Manager's built-in image update feature.
### 6. Troubleshooting
- **Container won't start**: Check logs via Container Manager or `docker logs kv-tube`
- **Port conflicts**: Ensure ports 5011 and 8080 are not used by other services
- **Permission denied**: Check the data folder permissions on your NAS
- **Slow playback**: Try lowering video quality or ensure sufficient network bandwidth
## Development
- Frontend builds can be started in `frontend/` via `npm run dev`.

View file

@ -5,17 +5,19 @@ version: '3.8'
services:
kv-tube:
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v4.0.9
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v5
container_name: kv-tube
platform: linux/amd64
restart: unless-stopped
ports:
- "5011:3000"
- "8080:8080"
volumes:
- ./data:/app/data
environment:
- KVTUBE_DATA_DIR=/app/data
- GIN_MODE=release
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
labels:
- "com.centurylinklabs.watchtower.enable=true"

View file

@ -191,7 +191,20 @@ export interface CommentData {
}
export async function getVideoComments(videoId: string, limit: number = 30): Promise<CommentData[]> {
const res = await fetch(`${API_BASE}/api/comments?v=${videoId}&limit=${limit}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json();
try {
const res = await fetch(`${API_BASE}/api/comments?v=${videoId}&limit=${limit}`, { cache: 'no-store' });
if (!res.ok) {
console.error('Comments API error:', res.status, res.statusText);
return [];
}
const data = await res.json();
if (!Array.isArray(data)) {
console.error('Comments API returned non-array:', data);
return [];
}
return data;
} catch (err) {
console.error('Failed to fetch comments:', err);
return [];
}
}

View file

@ -2,7 +2,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { useState } from 'react';
import { useState, useCallback } from 'react';
interface VideoData {
id: string;
@ -31,10 +31,20 @@ function getRelativeTime(id: string): string {
import { memo } from 'react';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
const [isNavigating, setIsNavigating] = useState(false);
const destination = video.list_id ? `/watch?v=${video.id}&list=${video.list_id}` : `/watch?v=${video.id}`;
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', marginBottom: '12px' }} className="videocard-container">
@ -44,19 +54,14 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}
>
<Image
src={video.thumbnail}
src={thumbnailSrc}
alt={video.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
className="videocard-thumb"
priority={false}
onError={(e) => {
const img = e.target as HTMLImageElement;
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
onError={handleImageError}
/>
{video.duration && !video.is_mix && (
<div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>

View file

@ -1,7 +1,9 @@
import Link from 'next/link';
'use client';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
import Link from 'next/link';
import { useState, useEffect, useCallback } from 'react';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
interface VideoData {
id: string;
@ -10,6 +12,7 @@ interface VideoData {
thumbnail: string;
view_count: number;
duration: string;
uploaded_date?: string;
}
interface Subscription {
@ -19,98 +22,188 @@ interface Subscription {
channel_avatar: string;
}
async function getHistory() {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/history?limit=20`, { cache: 'no-store' });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
async function getSubscriptions() {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
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 LibraryPage() {
const [history, subscriptions] = await Promise.all([getHistory(), getSubscriptions()]);
function getRelativeTime(id: string): string {
const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago'];
const index = (id.charCodeAt(0) || 0) % times.length;
return times[index];
}
function HistoryVideoCard({ video }: { video: VideoData }) {
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
const destination = `/watch?v=${video.id}`;
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}, []);
return (
<Link
href={destination}
className="videocard-container card-hover-lift"
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
borderRadius: '12px',
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={thumbnailSrc}
alt={video.title}
className="videocard-thumb"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={handleImageError}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<div className="videocard-info" style={{ padding: '0 4px' }}>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
marginBottom: '4px',
}}>
{video.title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
{video.uploader}
</p>
{video.view_count > 0 && (
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
{formatViews(video.view_count)} views
</p>
)}
</div>
</Link>
);
}
function SubscriptionCard({ subscription }: { subscription: Subscription }) {
return (
<Link
href={`/channel/${subscription.channel_id}`}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '16px',
borderRadius: '12px',
backgroundColor: 'var(--yt-hover)',
minWidth: '120px',
transition: 'background-color 0.2s',
textDecoration: 'none',
}}
className="card-hover-lift"
>
<div style={{
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '28px',
color: '#fff',
fontWeight: '600',
}}>
{subscription.channel_avatar || (subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?')}
</div>
<span style={{
fontSize: '14px',
fontWeight: '500',
color: 'var(--yt-text-primary)',
textAlign: 'center',
maxWidth: '100px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{subscription.channel_name || subscription.channel_id}
</span>
</Link>
);
}
export default function LibraryPage() {
const [history, setHistory] = useState<VideoData[]>([]);
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [historyRes, subsRes] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/history?limit=20`, { cache: 'no-store' }),
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' })
]);
const historyData = await historyRes.json();
const subsData = await subsRes.json();
setHistory(Array.isArray(historyData) ? historyData : []);
setSubscriptions(Array.isArray(subsData) ? subsData : []);
} catch (err) {
console.error('Failed to fetch library data:', err);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
if (loading) {
return (
<div style={{ padding: '48px', textAlign: 'center' }}>
<div style={{
width: '40px', height: '40px',
border: '3px solid var(--yt-border)',
borderTopColor: 'var(--yt-brand-red)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto'
}}></div>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* Subscriptions Section */}
{subscriptions.length > 0 && (
<section style={{ marginBottom: '40px' }}>
<h2 className="section-heading" style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
Subscriptions
</h2>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{subscriptions.map((sub) => (
<Link
key={sub.channel_id}
href={`/channel/${sub.channel_id}`}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '16px',
borderRadius: '12px',
backgroundColor: 'var(--yt-hover)',
minWidth: '120px',
transition: 'background-color 0.2s',
}}
className="card-hover-lift"
>
<div style={{
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '28px',
color: '#fff',
fontWeight: '600',
}}>
{sub.channel_avatar || (sub.channel_name ? sub.channel_name[0].toUpperCase() : '?')}
</div>
<span style={{
fontSize: '14px',
fontWeight: '500',
color: 'var(--yt-text-primary)',
textAlign: 'center',
maxWidth: '100px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{sub.channel_name || sub.channel_id}
</span>
</Link>
<SubscriptionCard key={sub.channel_id} subscription={sub} />
))}
</div>
</section>
)}
{/* Watch History Section */}
<section>
<h2 className="section-heading" style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
Watch History
</h2>
{history.length === 0 ? (
@ -131,65 +224,7 @@ export default async function LibraryPage() {
gap: '16px',
}}>
{history.map((video) => (
<Link
key={video.id}
href={`/watch?v=${video.id}`}
className="videocard-container card-hover-lift"
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
borderRadius: '12px',
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={video.thumbnail}
alt={video.title}
className="videocard-thumb"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const img = e.target as HTMLImageElement;
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<div className="videocard-info" style={{ padding: '0 4px' }}>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
marginBottom: '4px',
}}>
{video.title}
</h3>
<p style={{
fontSize: '12px',
color: 'var(--yt-text-secondary)',
}}>
{video.uploader}
</p>
{video.view_count > 0 && (
<p style={{
fontSize: '12px',
color: 'var(--yt-text-secondary)',
}}>
{formatViews(video.view_count)} views
</p>
)}
</div>
</Link>
<HistoryVideoCard key={video.id} video={video} />
))}
</div>
)}

View file

@ -1,7 +1,9 @@
import Link from 'next/link';
'use client';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
import Link from 'next/link';
import { useState, useEffect, useCallback } from 'react';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
interface VideoData {
id: string;
@ -11,6 +13,7 @@ interface VideoData {
thumbnail: string;
view_count: number;
duration: string;
uploaded_date?: string;
}
interface Subscription {
@ -20,27 +23,14 @@ interface Subscription {
channel_avatar: string;
}
async function getSubscriptions() {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
interface ChannelVideos {
subscription: Subscription;
videos: VideoData[];
}
async function getChannelVideos(channelId: string, limit: number = 5) {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/videos?id=${channelId}&limit=${limit}`, { cache: 'no-store' });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
const INITIAL_ROWS = 2;
const VIDEOS_PER_ROW = 5;
const MAX_ROWS = 5;
function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
@ -48,10 +38,203 @@ function formatViews(views: number): string {
return views.toString();
}
export default async function SubscriptionsPage() {
const subscriptions = await getSubscriptions();
function getRelativeTime(id: string): string {
const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago'];
const index = (id.charCodeAt(0) || 0) % times.length;
return times[index];
}
if (subscriptions.length === 0) {
function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVideos: ChannelVideos; defaultExpanded?: boolean }) {
const { subscription, videos } = channelVideos;
const [expanded, setExpanded] = useState(defaultExpanded);
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}, []);
if (videos.length === 0) return null;
const initialCount = INITIAL_ROWS * VIDEOS_PER_ROW;
const maxCount = MAX_ROWS * VIDEOS_PER_ROW;
const displayedVideos = expanded ? videos.slice(0, maxCount) : videos.slice(0, initialCount);
const hasMore = videos.length > initialCount;
return (
<section style={{ marginBottom: '32px' }}>
<Link
href={`/channel/${subscription.channel_id}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
padding: '0 12px',
}}
>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
color: '#fff',
fontWeight: '600',
}}>
{subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'}
</div>
<h2 style={{ fontSize: '18px', fontWeight: '500', color: 'var(--yt-text-primary)' }}>
{subscription.channel_name || subscription.channel_id}
</h2>
</Link>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '16px',
padding: '0 12px',
}}>
{displayedVideos.map((video) => {
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
const destination = `/watch?v=${video.id}`;
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
return (
<Link
key={video.id}
href={destination}
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
borderRadius: '12px',
overflow: 'hidden',
}}
className="card-hover-lift"
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={thumbnailSrc}
alt={video.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={handleImageError}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
margin: 0,
}}>
{video.title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', margin: 0 }}>
{formatViews(video.view_count)} views {relativeTime}
</p>
</Link>
);
})}
</div>
{hasMore && (
<div style={{ padding: '16px 12px 0', textAlign: 'left' }}>
<button
onClick={(e) => {
e.preventDefault();
setExpanded(!expanded);
}}
style={{
background: 'transparent',
border: 'none',
color: 'var(--yt-text-secondary)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
padding: '8px 16px',
borderRadius: '18px',
transition: 'background-color 0.2s',
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = 'var(--yt-hover)';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{expanded ? 'Show less' : `Show more (${videos.length - initialCount} more)`}
</button>
</div>
)}
</section>
);
}
export default function SubscriptionsPage() {
const [channelsVideos, setChannelsVideos] = useState<ChannelVideos[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const subsRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
const subsData = await subsRes.json();
const subs = Array.isArray(subsData) ? subsData : [];
const channelVideos: ChannelVideos[] = [];
for (const sub of subs) {
const videosRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/videos?id=${sub.channel_id}&limit=${MAX_ROWS * VIDEOS_PER_ROW}`,
{ cache: 'no-store' }
);
const videosData = await videosRes.json();
const videos = Array.isArray(videosData) ? videosData : [];
if (videos.length > 0) {
channelVideos.push({
subscription: sub,
videos: videos.map((v: VideoData) => ({ ...v, uploader: sub.channel_name }))
});
}
}
setChannelsVideos(channelVideos);
} catch (err) {
console.error('Failed to fetch subscriptions:', err);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
if (loading) {
return (
<div style={{ padding: '48px', textAlign: 'center' }}>
<div style={{
width: '40px', height: '40px',
border: '3px solid var(--yt-border)',
borderTopColor: 'var(--yt-brand-red)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto'
}}></div>
</div>
);
}
if (channelsVideos.length === 0) {
return (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--yt-text-secondary)' }}>
<h2 style={{ marginBottom: '16px', color: 'var(--yt-text-primary)' }}>No subscriptions yet</h2>
@ -60,102 +243,12 @@ export default async function SubscriptionsPage() {
);
}
const videosPerChannel = await Promise.all(
subscriptions.map(async (sub) => ({
subscription: sub,
videos: await getChannelVideos(sub.channel_id, 5),
}))
);
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px' }}>Subscriptions</h1>
<div style={{ padding: '12px', maxWidth: '1400px', margin: '0 auto' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px', padding: '0 12px' }}>Subscriptions</h1>
{videosPerChannel.map(({ subscription, videos }) => (
<section key={subscription.channel_id} style={{ marginBottom: '32px' }}>
<Link
href={`/channel/${subscription.channel_id}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
}}
>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
color: '#fff',
fontWeight: '600',
}}>
{subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'}
</div>
<h2 style={{ fontSize: '18px', fontWeight: '500' }}>{subscription.channel_name || subscription.channel_id}</h2>
</Link>
{videos.length > 0 ? (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '16px',
}}>
{videos.map((video) => (
<Link
key={video.id}
href={`/watch?v=${video.id}`}
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
borderRadius: '12px',
overflow: 'hidden',
}}
className="card-hover-lift"
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={video.thumbnail}
alt={video.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const img = e.target as HTMLImageElement;
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{video.title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
{formatViews(video.view_count)} views
</p>
</Link>
))}
</div>
) : (
<p style={{ color: 'var(--yt-text-secondary)', fontSize: '14px' }}>No videos available</p>
)}
</section>
{channelsVideos.map((channelData) => (
<ChannelSection key={channelData.subscription.channel_id} channelVideos={channelData} />
))}
</div>
);

View file

@ -23,8 +23,12 @@ export default function Comments({ videoId }: CommentsProps) {
getVideoComments(videoId, 40)
.then(data => {
if (isMounted) {
const topLevel = data.filter(c => !c.is_reply);
setComments(topLevel);
if (data.length === 0) {
setError(true);
} else {
const topLevel = data.filter(c => !c.is_reply);
setComments(topLevel);
}
setIsLoading(false);
}
})

View file

@ -5,6 +5,8 @@ import Image from 'next/image';
import { VideoData } from '../constants';
import { useEffect, useRef } from 'react';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
interface PlaylistPanelProps {
videos: VideoData[];
currentVideoId: string;
@ -12,11 +14,17 @@ interface PlaylistPanelProps {
title: string;
}
function handleImageError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}
export default function PlaylistPanel({ videos, currentVideoId, listId, title }: PlaylistPanelProps) {
const currentIndex = videos.findIndex(v => v.id === currentVideoId);
const activeItemRef = useRef<HTMLAnchorElement>(null);
// Auto-scroll to active item on mount
useEffect(() => {
if (activeItemRef.current) {
activeItemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@ -35,7 +43,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
marginBottom: '24px',
border: '1px solid var(--yt-border)'
}}>
{/* Header */}
<div style={{
padding: '16px',
borderBottom: '1px solid var(--yt-border)',
@ -49,7 +56,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
</div>
</div>
{/* List */}
<div style={{
overflowY: 'auto',
flex: 1,
@ -57,6 +63,8 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
}}>
{videos.map((video, index) => {
const isActive = video.id === currentVideoId;
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
return (
<Link
key={video.id}
@ -73,7 +81,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
}}
className="playlist-item-hover"
>
{/* Number or Playing Icon */}
<div style={{
width: '24px',
fontSize: '12px',
@ -84,7 +91,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
{isActive ? '▶' : index + 1}
</div>
{/* Thumbnail */}
<div style={{
position: 'relative',
width: '100px',
@ -93,19 +99,14 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
borderRadius: '8px',
overflow: 'hidden'
}}>
<Image
src={video.thumbnail}
alt={video.title}
fill
sizes="100px"
style={{ objectFit: 'cover' }}
onError={(e) => {
const img = e.target as HTMLImageElement;
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
/>
<Image
src={thumbnailSrc}
alt={video.title}
fill
sizes="100px"
style={{ objectFit: 'cover' }}
onError={handleImageError}
/>
{video.duration && (
<div style={{
position: 'absolute',
@ -123,7 +124,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
)}
</div>
{/* Info */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, justifyContent: 'center' }}>
<h4 style={{
margin: 0,

View file

@ -1,6 +1,9 @@
'use client';
import Link from 'next/link';
import { API_BASE } from '../constants';
import NextVideoClient from './NextVideoClient';
import { useState, useEffect, useCallback } from 'react';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
interface VideoData {
id: string;
@ -12,16 +15,9 @@ interface VideoData {
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 [];
}
interface RelatedVideosProps {
initialVideos: VideoData[];
nextVideoId: string;
}
function formatViews(views: number): string {
@ -30,50 +26,70 @@ function formatViews(views: number): string {
return views.toString();
}
export default async function RelatedVideos({ videoId, title, uploader }: { videoId: string, title: string, uploader: string }) {
const relatedVideos = await getRelatedVideos(videoId, title, uploader);
function RelatedVideoItem({ video, index }: { video: VideoData; index: number }) {
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
const views = formatViews(video.view_count);
const staggerClass = `stagger-${Math.min(index + 1, 6)}`;
if (relatedVideos.length === 0) {
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}, []);
return (
<Link
key={video.id}
href={`/watch?v=${video.id}`}
className={`related-video-item fade-in-up ${staggerClass}`}
style={{ opacity: 1 }}
>
<div className="related-thumb-container">
<img
src={thumbnailSrc}
alt={video.title}
className="related-thumb-img"
onError={handleImageError}
/>
{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>
);
}
export default function RelatedVideos({ initialVideos, nextVideoId }: RelatedVideosProps) {
const [videos, setVideos] = useState<VideoData[]>(initialVideos);
useEffect(() => {
setVideos(initialVideos);
}, [initialVideos]);
if (videos.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"
onError={(e) => {
const img = e.target as HTMLImageElement;
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
/>
{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>
);
})}
<Link href={`/watch?v=${nextVideoId}`} className="related-video-item fade-in-up" style={{ opacity: 1 }}>
<div className="related-thumb-container">
<div className="next-up-overlay">
<span>UP NEXT</span>
</div>
</div>
</Link>
{videos.map((video, i) => (
<RelatedVideoItem key={video.id} video={video} index={i} />
))}
</div>
);
}

View file

@ -396,9 +396,11 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
// Enhance buffer to mitigate Safari slow loading and choppiness
const hls = new window.Hls({
maxBufferLength: 60,
maxMaxBufferLength: 120,
maxBufferLength: 120,
maxMaxBufferLength: 240,
enableWorker: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 5,
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
@ -444,9 +446,11 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
if (audioHlsRef.current) audioHlsRef.current.destroy();
const audioHls = new window.Hls({
maxBufferLength: 60,
maxMaxBufferLength: 120,
maxBufferLength: 120,
maxMaxBufferLength: 240,
enableWorker: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 5,
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
@ -485,11 +489,23 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
video.addEventListener('pause', handlePauseForBackground);
// Keep playing when page visibility changes
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && !video.paused) {
video.play().catch(() => {});
if (audioRef.current && audioRef.current.paused) {
audioRef.current.play().catch(() => {});
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
video.removeEventListener('pause', handlePauseForBackground);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseWakeLock();
};
};

View file

@ -132,12 +132,13 @@ export default async function WatchPage({
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}/maxresdefault.jpg`,
thumbnail: baseInfo.thumbnail || `https://i.ytimg.com/vi/${mixBaseId}/hqdefault.jpg` || DEFAULT_THUMBNAIL,
view_count: baseInfo.view_count,
duration: ''
});
@ -149,9 +150,9 @@ export default async function WatchPage({
const titleKeywords = videoTitle.split(/[\s\-|]+/).filter((w: string) => w.length > 2).slice(0, 4).join(' ');
const mixPromises = [
uploaderName ? getSearchVideos(uploaderName, 10) : Promise.resolve([]),
titleKeywords ? getSearchVideos(titleKeywords, 10) : Promise.resolve([]),
getRelatedVideos(mixBaseId, 10),
uploaderName ? getSearchVideos(uploaderName, 20) : Promise.resolve([]),
titleKeywords ? getSearchVideos(titleKeywords, 20) : Promise.resolve([]),
getRelatedVideos(mixBaseId, 20),
];
const [byUploader, byTitle, byRelated] = await Promise.all(mixPromises);
@ -159,7 +160,7 @@ export default async function WatchPage({
const sources = [byUploader, byTitle, byRelated];
let added = 0;
const maxPlaylist = 25;
const maxPlaylist = 50;
let idx = [0, 0, 0];
while (added < maxPlaylist) {
@ -169,7 +170,10 @@ export default async function WatchPage({
const vid = sources[s][idx[s]++];
if (!seenMixIds.has(vid.id)) {
seenMixIds.add(vid.id);
playlistVideos.push(vid);
playlistVideos.push({
...vid,
thumbnail: vid.thumbnail || `https://i.ytimg.com/vi/${vid.id}/hqdefault.jpg` || DEFAULT_THUMBNAIL
});
added++;
anyAdded = true;
break;

View file

@ -76,7 +76,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -1616,7 +1615,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1682,7 +1680,6 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@ -2211,7 +2208,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2564,7 +2560,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3138,7 +3133,6 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -3324,7 +3318,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -5564,7 +5557,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -5574,7 +5566,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -6272,7 +6263,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -6435,7 +6425,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -6745,7 +6734,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}