Fix Watch history, Subscriptions, Safari playback; Bump v4.0.2

This commit is contained in:
KV-Tube Deployer 2026-02-23 06:47:18 +07:00
parent b7ea9165a1
commit 7c00855c4c
8 changed files with 91 additions and 16 deletions

View file

@ -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. - **Modern Video Player**: High-resolution video playback with HLS support and quality selection.
- **Fast Navigation**: Instant click feedback with skeleton loaders for related videos. - **Fast Navigation**: Instant click feedback with skeleton loaders for related videos.
- **Infinite Scrolling**: Scroll seamlessly through a dynamic video grid on the homepage. - **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). - **Region Selection**: Tailor your content to specific regions (e.g., Vietnam).
- **Responsive Design**: Beautiful, mobile-friendly interface with light and dark theme support. - **Responsive Design**: Beautiful, mobile-friendly interface with light and dark theme support.
- **Containerized**: Fully Dockerized for easy setup using `docker-compose`. - **Containerized**: Fully Dockerized for easy setup using `docker-compose`.
@ -34,7 +36,7 @@ version: '3.8'
services: services:
kv-tube-app: 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 container_name: kv-tube-app
restart: unless-stopped restart: unless-stopped
ports: ports:

15
docker-compose.local.yml Normal file
View file

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

View file

@ -5,7 +5,7 @@ version: '3.8'
services: services:
kv-tube-app: 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 container_name: kv-tube-app
platform: linux/amd64 platform: linux/amd64
restart: unless-stopped restart: unless-stopped

View file

@ -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 });
}
}

View file

@ -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) { export async function GET(request: NextRequest) {
const channelId = request.nextUrl.searchParams.get('channel_id'); const channelId = request.nextUrl.searchParams.get('channel_id');
if (!channelId) { if (!channelId) {
return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); 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)}`, { const res = await fetch(`${API_BASE}/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, {
cache: 'no-store', cache: 'no-store',
}); });
if (!res.ok) { if (!res.ok) {
return NextResponse.json({ subscribed: false }); return NextResponse.json({ subscribed: false });
} }
const data = await res.json(); const data = await res.json();
return NextResponse.json({ subscribed: data.subscribed || false }); return NextResponse.json({ subscribed: data.subscribed || false });
} catch (error) { } catch (error) {
@ -28,8 +28,8 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { channel_id, channel_name } = body; const { channel_id, channel_name, channel_avatar } = body;
if (!channel_id) { if (!channel_id) {
return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); return NextResponse.json({ error: 'No channel ID' }, { status: 400 });
} }
@ -40,14 +40,15 @@ export async function POST(request: NextRequest) {
body: JSON.stringify({ body: JSON.stringify({
channel_id, channel_id,
channel_name: channel_name || channel_id, channel_name: channel_name || channel_id,
channel_avatar: channel_avatar || '?',
}), }),
cache: 'no-store', cache: 'no-store',
}); });
if (!res.ok) { if (!res.ok) {
return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 }); return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 });
} }
const data = await res.json(); const data = await res.json();
return NextResponse.json({ success: true, ...data }); return NextResponse.json({ success: true, ...data });
} catch (error) { } catch (error) {
@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
const channelId = request.nextUrl.searchParams.get('channel_id'); const channelId = request.nextUrl.searchParams.get('channel_id');
if (!channelId) { if (!channelId) {
return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); return NextResponse.json({ error: 'No channel ID' }, { status: 400 });
} }
@ -67,11 +68,11 @@ export async function DELETE(request: NextRequest) {
method: 'DELETE', method: 'DELETE',
cache: 'no-store', cache: 'no-store',
}); });
if (!res.ok) { if (!res.ok) {
return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 }); return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 });
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 }); return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 });

View file

@ -10,7 +10,7 @@ export default function Sidebar() {
const navItems = [ const navItems = [
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' }, { icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
{ icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' }, // { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' }, { icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' }, { icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
]; ];

View file

@ -41,6 +41,7 @@ export default function SubscribeButton({ channelId, channelName, initialSubscri
body: JSON.stringify({ body: JSON.stringify({
channel_id: channelId, channel_id: channelId,
channel_name: channelName || channelId, channel_name: channelName || channelId,
channel_avatar: channelName ? channelName[0].toUpperCase() : '?',
}), }),
}); });
if (res.ok) { if (res.ok) {

View file

@ -97,7 +97,8 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
const audio = audioRef.current; const audio = audioRef.current;
if (!video || !audio || !hasSeparateAudio) return; 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; audio.currentTime = video.currentTime;
} }
@ -148,6 +149,17 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
tryLoad(); 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 () => { return () => {
if (hlsRef.current) { if (hlsRef.current) {
hlsRef.current.destroy(); hlsRef.current.destroy();
@ -206,7 +218,11 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
if (isHLS && window.Hls && window.Hls.isSupported()) { if (isHLS && window.Hls && window.Hls.isSupported()) {
if (hlsRef.current) hlsRef.current.destroy(); if (hlsRef.current) hlsRef.current.destroy();
// Enhance buffer to mitigate Safari slow loading and choppiness
const hls = new window.Hls({ const hls = new window.Hls({
maxBufferLength: 60,
maxMaxBufferLength: 120,
enableWorker: true,
xhrSetup: (xhr: XMLHttpRequest) => { xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
}, },
@ -227,7 +243,7 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
setUseFallback(true); setUseFallback(true);
} }
}); });
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (isHLS && video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = streamUrl; video.src = streamUrl;
video.onloadedmetadata = () => video.play().catch(() => { }); video.onloadedmetadata = () => video.play().catch(() => { });
} else { } else {
@ -244,6 +260,9 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
if (audioHlsRef.current) audioHlsRef.current.destroy(); if (audioHlsRef.current) audioHlsRef.current.destroy();
const audioHls = new window.Hls({ const audioHls = new window.Hls({
maxBufferLength: 60,
maxMaxBufferLength: 120,
enableWorker: true,
xhrSetup: (xhr: XMLHttpRequest) => { xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
}, },
@ -252,6 +271,8 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
audioHls.loadSource(audioStreamUrl!); audioHls.loadSource(audioStreamUrl!);
audioHls.attachMedia(audio); audioHls.attachMedia(audio);
} else if (audioIsHLS && audio.canPlayType('application/vnd.apple.mpegurl')) {
audio.src = audioStreamUrl!;
} else { } else {
audio.src = audioStreamUrl!; audio.src = audioStreamUrl!;
} }