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!;
}