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.
|
||||
- **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:
|
||||
|
|
|
|||
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:
|
||||
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
|
||||
|
|
|
|||
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) {
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export default function Sidebar() {
|
|||
|
||||
const navItems = [
|
||||
{ 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: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue