feat: Update subscriptions, comments, thumbnails and video player
- 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:
parent
82a51b7ee4
commit
468b2b08fc
13 changed files with 599 additions and 377 deletions
|
|
@ -1,16 +0,0 @@
|
|||
.venv/
|
||||
.venv_clean/
|
||||
env/
|
||||
__pycache__/
|
||||
.git/
|
||||
.DS_Store
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.idea/
|
||||
.vscode/
|
||||
videos/
|
||||
data/
|
||||
venv/
|
||||
.gemini/
|
||||
tmp*/
|
||||
70
README.md
70
README.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue