From 7c00855c4c9e487746e25f03049922d5f7a7876e Mon Sep 17 00:00:00 2001 From: KV-Tube Deployer Date: Mon, 23 Feb 2026 06:47:18 +0700 Subject: [PATCH] Fix Watch history, Subscriptions, Safari playback; Bump v4.0.2 --- README.md | 6 ++-- docker-compose.local.yml | 15 +++++++++ docker-compose.yml | 2 +- frontend/app/api/history/route.ts | 35 +++++++++++++++++++++ frontend/app/api/subscribe/route.ts | 21 +++++++------ frontend/app/components/Sidebar.tsx | 2 +- frontend/app/components/SubscribeButton.tsx | 1 + frontend/app/watch/VideoPlayer.tsx | 25 +++++++++++++-- 8 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 docker-compose.local.yml create mode 100644 frontend/app/api/history/route.ts diff --git a/README.md b/README.md index fc22bba..8369a19 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ A modern, fast, and fully-featured YouTube-like video streaming platform. Built - **Modern Video Player**: High-resolution video playback with HLS support and quality selection. - **Fast Navigation**: Instant click feedback with skeleton loaders for related videos. - **Infinite Scrolling**: Scroll seamlessly through a dynamic video grid on the homepage. -- **Watch History & Suggestions**: Keep track of what you've watched, with smart video suggestions. +- **Watch History & Suggestions**: Keep track of what you've watched seamlessly! Fully integrated library history tracking. +- **Subscriptions Management**: Keep up to date with seamless subscription updates for YouTube channels. +- **Optimized for Safari**: Stutter-free playback algorithms and high-tolerance Hls.js configurations tailored for macOS users. - **Region Selection**: Tailor your content to specific regions (e.g., Vietnam). - **Responsive Design**: Beautiful, mobile-friendly interface with light and dark theme support. - **Containerized**: Fully Dockerized for easy setup using `docker-compose`. @@ -34,7 +36,7 @@ version: '3.8' services: kv-tube-app: - image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.1 + image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.2 container_name: kv-tube-app restart: unless-stopped ports: diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..771b682 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,15 @@ +version: '3.8' + +services: + kv-tube-app-local: + build: . + container_name: kv-tube-app-local + platform: linux/amd64 + ports: + - "5012:3000" + volumes: + - ./data:/app/data + environment: + - KVTUBE_DATA_DIR=/app/data + - GIN_MODE=release + - NODE_ENV=production diff --git a/docker-compose.yml b/docker-compose.yml index 742f4c6..cb35fb9 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ version: '3.8' services: kv-tube-app: - image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.1 + image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.2 container_name: kv-tube-app platform: linux/amd64 restart: unless-stopped diff --git a/frontend/app/api/history/route.ts b/frontend/app/api/history/route.ts new file mode 100644 index 0000000..4859674 --- /dev/null +++ b/frontend/app/api/history/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { video_id, title, thumbnail } = body; + + if (!video_id) { + return NextResponse.json({ error: 'No video ID' }, { status: 400 }); + } + + const res = await fetch(`${API_BASE}/api/history`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + video_id, + title: title || `Video ${video_id}`, + thumbnail: thumbnail || `https://i.ytimg.com/vi/${video_id}/hqdefault.jpg`, + }), + cache: 'no-store', + }); + + if (!res.ok) { + return NextResponse.json({ error: 'Failed to record history' }, { status: res.status }); + } + + const data = await res.json(); + return NextResponse.json({ success: true, ...data }); + } catch (error) { + console.error('API /api/history POST error:', error); + return NextResponse.json({ error: 'Failed to record history' }, { status: 500 }); + } +} diff --git a/frontend/app/api/subscribe/route.ts b/frontend/app/api/subscribe/route.ts index 1a2766b..3115ca5 100755 --- a/frontend/app/api/subscribe/route.ts +++ b/frontend/app/api/subscribe/route.ts @@ -4,7 +4,7 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; export async function GET(request: NextRequest) { const channelId = request.nextUrl.searchParams.get('channel_id'); - + if (!channelId) { return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); } @@ -13,11 +13,11 @@ export async function GET(request: NextRequest) { const res = await fetch(`${API_BASE}/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, { cache: 'no-store', }); - + if (!res.ok) { return NextResponse.json({ subscribed: false }); } - + const data = await res.json(); return NextResponse.json({ subscribed: data.subscribed || false }); } catch (error) { @@ -28,8 +28,8 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { const body = await request.json(); - const { channel_id, channel_name } = body; - + const { channel_id, channel_name, channel_avatar } = body; + if (!channel_id) { return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); } @@ -40,14 +40,15 @@ export async function POST(request: NextRequest) { body: JSON.stringify({ channel_id, channel_name: channel_name || channel_id, + channel_avatar: channel_avatar || '?', }), cache: 'no-store', }); - + if (!res.ok) { return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 }); } - + const data = await res.json(); return NextResponse.json({ success: true, ...data }); } catch (error) { @@ -57,7 +58,7 @@ export async function POST(request: NextRequest) { export async function DELETE(request: NextRequest) { const channelId = request.nextUrl.searchParams.get('channel_id'); - + if (!channelId) { return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); } @@ -67,11 +68,11 @@ export async function DELETE(request: NextRequest) { method: 'DELETE', cache: 'no-store', }); - + if (!res.ok) { return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 }); } - + return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 }); diff --git a/frontend/app/components/Sidebar.tsx b/frontend/app/components/Sidebar.tsx index ed374d4..54fb35c 100755 --- a/frontend/app/components/Sidebar.tsx +++ b/frontend/app/components/Sidebar.tsx @@ -10,7 +10,7 @@ export default function Sidebar() { const navItems = [ { icon: , label: 'Home', path: '/' }, - { icon: , label: 'Shorts', path: '/shorts' }, + // { icon: , label: 'Shorts', path: '/shorts' }, { icon: , label: 'Subscriptions', path: '/feed/subscriptions' }, { icon: , label: 'You', path: '/feed/library' }, ]; diff --git a/frontend/app/components/SubscribeButton.tsx b/frontend/app/components/SubscribeButton.tsx index c66b86f..27c9b34 100755 --- a/frontend/app/components/SubscribeButton.tsx +++ b/frontend/app/components/SubscribeButton.tsx @@ -41,6 +41,7 @@ export default function SubscribeButton({ channelId, channelName, initialSubscri body: JSON.stringify({ channel_id: channelId, channel_name: channelName || channelId, + channel_avatar: channelName ? channelName[0].toUpperCase() : '?', }), }); if (res.ok) { diff --git a/frontend/app/watch/VideoPlayer.tsx b/frontend/app/watch/VideoPlayer.tsx index 9d0c1ef..1db02f2 100755 --- a/frontend/app/watch/VideoPlayer.tsx +++ b/frontend/app/watch/VideoPlayer.tsx @@ -97,7 +97,8 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) { const audio = audioRef.current; if (!video || !audio || !hasSeparateAudio) return; - if (Math.abs(video.currentTime - audio.currentTime) > 0.2) { + // Relax the tolerance to 0.4s to prevent choppy audio resetting on Safari + if (Math.abs(video.currentTime - audio.currentTime) > 0.4) { audio.currentTime = video.currentTime; } @@ -148,6 +149,17 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) { tryLoad(); + // Record history once per videoId + fetch('/api/history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + video_id: videoId, + title: title, + thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, + }), + }).catch(err => console.error('Failed to record history', err)); + return () => { if (hlsRef.current) { hlsRef.current.destroy(); @@ -206,7 +218,11 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) { if (isHLS && window.Hls && window.Hls.isSupported()) { if (hlsRef.current) hlsRef.current.destroy(); + // Enhance buffer to mitigate Safari slow loading and choppiness const hls = new window.Hls({ + maxBufferLength: 60, + maxMaxBufferLength: 120, + enableWorker: true, xhrSetup: (xhr: XMLHttpRequest) => { xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); }, @@ -227,7 +243,7 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) { setUseFallback(true); } }); - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + } else if (isHLS && video.canPlayType('application/vnd.apple.mpegurl')) { video.src = streamUrl; video.onloadedmetadata = () => video.play().catch(() => { }); } else { @@ -244,6 +260,9 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) { if (audioHlsRef.current) audioHlsRef.current.destroy(); const audioHls = new window.Hls({ + maxBufferLength: 60, + maxMaxBufferLength: 120, + enableWorker: true, xhrSetup: (xhr: XMLHttpRequest) => { xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); }, @@ -252,6 +271,8 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) { audioHls.loadSource(audioStreamUrl!); audioHls.attachMedia(audio); + } else if (audioIsHLS && audio.canPlayType('application/vnd.apple.mpegurl')) { + audio.src = audioStreamUrl!; } else { audio.src = audioStreamUrl!; }