Fix Watch history, Subscriptions, Safari playback; Bump v4.0.2
This commit is contained in:
parent
b7ea9165a1
commit
7c00855c4c
8 changed files with 91 additions and 16 deletions
|
|
@ -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
15
docker-compose.local.yml
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
35
frontend/app/api/history/route.ts
Normal file
35
frontend/app/api/history/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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!;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue