diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index b100ed6..0000000 --- a/.dockerignore +++ /dev/null @@ -1,16 +0,0 @@ -.venv/ -.venv_clean/ -env/ -__pycache__/ -.git/ -.DS_Store -*.pyc -*.pyo -*.pyd -.idea/ -.vscode/ -videos/ -data/ -venv/ -.gemini/ -tmp*/ diff --git a/README.md b/README.md index 499af31..12771d3 100644 --- a/README.md +++ b/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://:5011` +- **Frontend**: `http://:5011` +- **Backend API**: `http://: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`. diff --git a/docker-compose.yml b/docker-compose.yml index aff50b8..950171e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/frontend/app/actions.ts b/frontend/app/actions.ts index afe3868..c0e92de 100644 --- a/frontend/app/actions.ts +++ b/frontend/app/actions.ts @@ -191,7 +191,20 @@ export interface CommentData { } export async function getVideoComments(videoId: string, limit: number = 30): Promise { - 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 []; + } } diff --git a/frontend/app/components/VideoCard.tsx b/frontend/app/components/VideoCard.tsx index 21cb76b..1978581 100644 --- a/frontend/app/components/VideoCard.tsx +++ b/frontend/app/components/VideoCard.tsx @@ -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) => { + const img = e.target as HTMLImageElement; + if (img.src !== DEFAULT_THUMBNAIL) { + img.src = DEFAULT_THUMBNAIL; + } + }, []); return (
@@ -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' }} > {video.title} { - 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 && (
diff --git a/frontend/app/feed/library/page.tsx b/frontend/app/feed/library/page.tsx index b477f65..8f52296 100644 --- a/frontend/app/feed/library/page.tsx +++ b/frontend/app/feed/library/page.tsx @@ -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) => { + const img = e.target as HTMLImageElement; + if (img.src !== DEFAULT_THUMBNAIL) { + img.src = DEFAULT_THUMBNAIL; + } + }, []); + + return ( + +
+ {video.title} + {video.duration && ( +
{video.duration}
+ )} +
+
+

+ {video.title} +

+

+ {video.uploader} +

+ {video.view_count > 0 && ( +

+ {formatViews(video.view_count)} views +

+ )} +
+ + ); +} + +function SubscriptionCard({ subscription }: { subscription: Subscription }) { + return ( + +
+ {subscription.channel_avatar || (subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?')} +
+ + {subscription.channel_name || subscription.channel_id} + + + ); +} + +export default function LibraryPage() { + const [history, setHistory] = useState([]); + const [subscriptions, setSubscriptions] = useState([]); + 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 ( +
+
+
+ ); + } return (
- {/* Subscriptions Section */} {subscriptions.length > 0 && (
-

+

Subscriptions

{subscriptions.map((sub) => ( - -
- {sub.channel_avatar || (sub.channel_name ? sub.channel_name[0].toUpperCase() : '?')} -
- - {sub.channel_name || sub.channel_id} - - + ))}
)} - {/* Watch History Section */}
-

+

Watch History

{history.length === 0 ? ( @@ -131,65 +224,7 @@ export default async function LibraryPage() { gap: '16px', }}> {history.map((video) => ( - -
- {video.title} { - 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 && ( -
{video.duration}
- )} -
-
-

- {video.title} -

-

- {video.uploader} -

- {video.view_count > 0 && ( -

- {formatViews(video.view_count)} views -

- )} -
- + ))}
)} diff --git a/frontend/app/feed/subscriptions/page.tsx b/frontend/app/feed/subscriptions/page.tsx index 6a8241c..c5254eb 100644 --- a/frontend/app/feed/subscriptions/page.tsx +++ b/frontend/app/feed/subscriptions/page.tsx @@ -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) => { + 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 ( +
+ +
+ {subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'} +
+

+ {subscription.channel_name || subscription.channel_id} +

+ + +
+ {displayedVideos.map((video) => { + const relativeTime = video.uploaded_date || getRelativeTime(video.id); + const destination = `/watch?v=${video.id}`; + const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL; + + return ( + +
+ {video.title} + {video.duration && ( +
{video.duration}
+ )} +
+

+ {video.title} +

+

+ {formatViews(video.view_count)} views • {relativeTime} +

+ + ); + })} +
+ + {hasMore && ( +
+ +
+ )} +
+ ); +} + +export default function SubscriptionsPage() { + const [channelsVideos, setChannelsVideos] = useState([]); + 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 ( +
+
+
+ ); + } + + if (channelsVideos.length === 0) { return (

No subscriptions yet

@@ -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 ( -
-

Subscriptions

+
+

Subscriptions

- {videosPerChannel.map(({ subscription, videos }) => ( -
- -
- {subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'} -
-

{subscription.channel_name || subscription.channel_id}

- - - {videos.length > 0 ? ( -
- {videos.map((video) => ( - -
- {video.title} { - 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 && ( -
{video.duration}
- )} -
-

- {video.title} -

-

- {formatViews(video.view_count)} views -

- - ))} -
- ) : ( -

No videos available

- )} -
+ {channelsVideos.map((channelData) => ( + ))}
); diff --git a/frontend/app/watch/Comments.tsx b/frontend/app/watch/Comments.tsx index ab04afe..0374386 100644 --- a/frontend/app/watch/Comments.tsx +++ b/frontend/app/watch/Comments.tsx @@ -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); } }) diff --git a/frontend/app/watch/PlaylistPanel.tsx b/frontend/app/watch/PlaylistPanel.tsx index 7a5c32d..d279e4b 100644 --- a/frontend/app/watch/PlaylistPanel.tsx +++ b/frontend/app/watch/PlaylistPanel.tsx @@ -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) { + 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(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 */}
- {/* List */}
{videos.map((video, index) => { const isActive = video.id === currentVideoId; + const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL; + return ( - {/* Number or Playing Icon */}
- {/* Thumbnail */}
- {video.title} { - 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.title} {video.duration && (
- {/* Info */}

; - } 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) => { + const img = e.target as HTMLImageElement; + if (img.src !== DEFAULT_THUMBNAIL) { + img.src = DEFAULT_THUMBNAIL; + } + }, []); + + return ( + +
+ {video.title} + {video.duration && ( +
+ {video.duration} +
+ )} +
+
+ {video.title} + {video.uploader} + {views} views +
+ + ); +} + +export default function RelatedVideos({ initialVideos, nextVideoId }: RelatedVideosProps) { + const [videos, setVideos] = useState(initialVideos); + + useEffect(() => { + setVideos(initialVideos); + }, [initialVideos]); + + if (videos.length === 0) { return
No related videos found.
; } - const nextVideoId = relatedVideos[0].id; - return (
- - {relatedVideos.map((video, i) => { - const views = formatViews(video.view_count); - const staggerClass = `stagger-${Math.min(i + 1, 6)}`; - - return ( - -
- {video.title} { - 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 && ( -
- {video.duration} -
- )} -
-
- {video.title} - {video.uploader} - {views} views -
- - ); - })} + +
+
+ UP NEXT +
+
+ + {videos.map((video, i) => ( + + ))}
); } diff --git a/frontend/app/watch/VideoPlayer.tsx b/frontend/app/watch/VideoPlayer.tsx index 4a9d651..0f6e34d 100644 --- a/frontend/app/watch/VideoPlayer.tsx +++ b/frontend/app/watch/VideoPlayer.tsx @@ -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(); }; }; diff --git a/frontend/app/watch/page.tsx b/frontend/app/watch/page.tsx index e6803d5..e56fed7 100644 --- a/frontend/app/watch/page.tsx +++ b/frontend/app/watch/page.tsx @@ -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; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7130d63..c770bfc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }