mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
refactor(video-page): split video page into modular components, hooks, and shared types
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface UseCommentExportParams {
|
||||
activeVersionId: string | null;
|
||||
showResolved: boolean;
|
||||
}
|
||||
|
||||
export function useCommentExport({ activeVersionId, showResolved }: UseCommentExportParams) {
|
||||
const [isExportingCsv, setIsExportingCsv] = useState(false);
|
||||
const [isExportingPdf, setIsExportingPdf] = useState(false);
|
||||
|
||||
const exportComments = useCallback(
|
||||
async (format: 'csv' | 'pdf') => {
|
||||
if (!activeVersionId) return;
|
||||
|
||||
if (format === 'csv') {
|
||||
setIsExportingCsv(true);
|
||||
} else {
|
||||
setIsExportingPdf(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/versions/${activeVersionId}/comments/export?format=${format}&includeResolved=${showResolved}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let message = 'Failed to export comments';
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (typeof data?.error === 'string') {
|
||||
message = data.error;
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback message when response is not JSON.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get('content-disposition');
|
||||
const fallbackName = `comments.${format}`;
|
||||
const matched = disposition?.match(/filename="?([^"]+)"?/i);
|
||||
const filename = matched?.[1] || fallbackName;
|
||||
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
toast.success(`Comments exported as ${format.toUpperCase()}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export comments:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to export comments');
|
||||
} finally {
|
||||
if (format === 'csv') {
|
||||
setIsExportingCsv(false);
|
||||
} else {
|
||||
setIsExportingPdf(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeVersionId, showResolved]
|
||||
);
|
||||
|
||||
return {
|
||||
isExportingCsv,
|
||||
isExportingPdf,
|
||||
exportComments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useCommentMedia() {
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const [voiceProgress, setVoiceProgress] = useState(0);
|
||||
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
|
||||
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
|
||||
|
||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
const voiceRafRef = useRef<number | null>(null);
|
||||
const voiceKnownDurationRef = useRef<number>(0);
|
||||
|
||||
const stopVoiceTracking = useCallback(() => {
|
||||
if (voiceRafRef.current) {
|
||||
cancelAnimationFrame(voiceRafRef.current);
|
||||
voiceRafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startVoiceTracking = useCallback(() => {
|
||||
stopVoiceTracking();
|
||||
const tick = () => {
|
||||
const audio = audioPlayerRef.current;
|
||||
if (audio) {
|
||||
const dur = isFinite(audio.duration) && audio.duration > 0
|
||||
? audio.duration
|
||||
: voiceKnownDurationRef.current;
|
||||
if (dur > 0) {
|
||||
setVoiceProgress((audio.currentTime / dur) * 100);
|
||||
setVoiceCurrentTime(audio.currentTime);
|
||||
}
|
||||
}
|
||||
voiceRafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
voiceRafRef.current = requestAnimationFrame(tick);
|
||||
}, [stopVoiceTracking]);
|
||||
|
||||
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
|
||||
if (playingVoiceId === commentId) {
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
audioPlayerRef.current = null;
|
||||
}
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
}
|
||||
stopVoiceTracking();
|
||||
|
||||
voiceKnownDurationRef.current = knownDuration || 0;
|
||||
const audio = new Audio(voiceUrl);
|
||||
audio.playbackRate = voicePlaybackRate;
|
||||
audioPlayerRef.current = audio;
|
||||
setPlayingVoiceId(commentId);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
|
||||
audio.onplay = () => {
|
||||
startVoiceTracking();
|
||||
};
|
||||
|
||||
audio.onended = () => {
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
audioPlayerRef.current = null;
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
audioPlayerRef.current = null;
|
||||
};
|
||||
|
||||
void audio.play();
|
||||
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
|
||||
|
||||
const toggleVoiceSpeed = useCallback(() => {
|
||||
setVoicePlaybackRate((prev) => {
|
||||
const next = prev === 1 ? 2 : 1;
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.playbackRate = next;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
audioPlayerRef.current = null;
|
||||
}
|
||||
stopVoiceTracking();
|
||||
};
|
||||
}, [stopVoiceTracking]);
|
||||
|
||||
return {
|
||||
playingVoiceId,
|
||||
voiceProgress,
|
||||
voiceCurrentTime,
|
||||
voicePlaybackRate,
|
||||
playVoice,
|
||||
toggleVoiceSpeed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
|
||||
|
||||
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
|
||||
function sanitizeDownloadFileName(value: string): string {
|
||||
return value
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getAllowedHosts() {
|
||||
return [
|
||||
BUNNY_PULL_ZONE_HOSTNAME,
|
||||
...(process.env.NEXT_PUBLIC_BUNNY_CDN_URL
|
||||
? (() => {
|
||||
try {
|
||||
return [new URL(process.env.NEXT_PUBLIC_BUNNY_CDN_URL).hostname];
|
||||
} catch {
|
||||
return [process.env.NEXT_PUBLIC_BUNNY_CDN_URL.replace(/^https?:\/\//, '').replace(/\/+$/, '')];
|
||||
}
|
||||
})()
|
||||
: []),
|
||||
...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','),
|
||||
]
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getSafeDirectDownloadUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedHosts = getAllowedHosts();
|
||||
if (allowedHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedHost = parsed.hostname.toLowerCase();
|
||||
if (!allowedHosts.includes(normalizedHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface UseDownloadActionsParams {
|
||||
activeVersion: (Version & { comments: Comment[] }) | undefined;
|
||||
video: VideoData | null;
|
||||
}
|
||||
|
||||
export function useDownloadActions({ activeVersion, video }: UseDownloadActionsParams) {
|
||||
const [activeDownloadTarget, setActiveDownloadTarget] = useState<DownloadTarget | null>(null);
|
||||
const isDownloadingVideo = activeDownloadTarget !== null;
|
||||
|
||||
const startDownload = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => {
|
||||
if (!activeVersion || !video || isDownloadingVideo) return;
|
||||
if (!video.canDownload) {
|
||||
toast.error('Download is disabled for this shared link');
|
||||
return;
|
||||
}
|
||||
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
|
||||
toast.error('This video source does not support direct download');
|
||||
return;
|
||||
}
|
||||
|
||||
const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct';
|
||||
setActiveDownloadTarget(target);
|
||||
try {
|
||||
let downloadUrl: string | null = null;
|
||||
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
const prepareRes = await fetch(`/api/versions/${activeVersion.id}/download?source=${preference}&prepare=1`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!prepareRes.ok) {
|
||||
const prepareBody = await prepareRes.json().catch(() => null);
|
||||
const fallbackError = preference === 'original'
|
||||
? 'Original file is not available for this video'
|
||||
: 'Compressed file is not available for this video';
|
||||
const errorMessage = typeof prepareBody?.error === 'string'
|
||||
? prepareBody.error
|
||||
: fallbackError;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
|
||||
} else {
|
||||
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
|
||||
if (!downloadUrl) {
|
||||
throw new Error('Direct download URL is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw new Error('Missing download URL');
|
||||
}
|
||||
|
||||
const versionLabel = activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`;
|
||||
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
if (activeVersion.providerId === 'direct') {
|
||||
a.download = `${baseName}.mp4`;
|
||||
}
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch (error) {
|
||||
console.error('Failed to start video download:', error);
|
||||
if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
|
||||
toast.error('This direct download host is not allowed');
|
||||
} else if (error instanceof Error && error.message) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error('Failed to start download');
|
||||
}
|
||||
} finally {
|
||||
setActiveDownloadTarget(null);
|
||||
}
|
||||
}, [activeVersion, video, isDownloadingVideo]);
|
||||
|
||||
return {
|
||||
activeDownloadTarget,
|
||||
isDownloadingVideo,
|
||||
startDownload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import * as tus from 'tus-js-client';
|
||||
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
|
||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||
|
||||
interface UseVersionActionsParams extends VersionActionsConfig {
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
activeVersionId: string | null;
|
||||
setActiveVersionId: Dispatch<SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
export function useVersionActions({
|
||||
projectId,
|
||||
videoId,
|
||||
setVideo,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
}: UseVersionActionsParams) {
|
||||
const [showVersionDialog, setShowVersionDialog] = useState(false);
|
||||
const [newVersionUrl, setNewVersionUrl] = useState('');
|
||||
const [newVersionLabel, setNewVersionLabel] = useState('');
|
||||
const [newVersionSource, setNewVersionSource] = useState<VideoSource | null>(null);
|
||||
const [newVersionUrlError, setNewVersionUrlError] = useState('');
|
||||
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
||||
const [newVersionMode, setNewVersionMode] = useState<'url' | 'file'>('url');
|
||||
const [newVersionFile, setNewVersionFile] = useState<File | null>(null);
|
||||
const [newVersionUploadProgress, setNewVersionUploadProgress] = useState(0);
|
||||
const [newVersionUploadStatus, setNewVersionUploadStatus] = useState('');
|
||||
|
||||
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
|
||||
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
|
||||
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
|
||||
|
||||
const handleNewVersionUrlChange = (url: string) => {
|
||||
setNewVersionUrl(url);
|
||||
setNewVersionUrlError('');
|
||||
if (!url.trim()) {
|
||||
setNewVersionSource(null);
|
||||
return;
|
||||
}
|
||||
const source = parseVideoUrl(url);
|
||||
if (source) {
|
||||
setNewVersionSource(source);
|
||||
} else {
|
||||
setNewVersionSource(null);
|
||||
if (url.length > 10) setNewVersionUrlError('Unsupported URL');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
if (!projectId) return;
|
||||
setIsCreatingVersion(true);
|
||||
setNewVersionUploadStatus('');
|
||||
setNewVersionUploadProgress(0);
|
||||
let uploadedBunnyVideoId: string | null = null;
|
||||
let uploadedBunnyUploadToken: string | null = null;
|
||||
|
||||
try {
|
||||
let finalVideoUrl = '';
|
||||
let finalProviderId = '';
|
||||
let finalProviderVideoId = '';
|
||||
let finalThumbnailUrl: string | null = null;
|
||||
let finalDuration: number | null = null;
|
||||
|
||||
if (newVersionMode === 'url') {
|
||||
if (!newVersionSource) throw new Error('Invalid URL');
|
||||
const meta = await fetchVideoMetadata(newVersionSource);
|
||||
finalVideoUrl = newVersionSource.originalUrl;
|
||||
finalProviderId = newVersionSource.providerId;
|
||||
finalProviderVideoId = newVersionSource.videoId;
|
||||
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
||||
finalDuration = meta?.duration || null;
|
||||
} else {
|
||||
if (!newVersionFile) throw new Error('No file selected');
|
||||
let title = newVersionFile.name;
|
||||
if (newVersionLabel.trim()) {
|
||||
title = newVersionLabel.trim();
|
||||
} else {
|
||||
title = title.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||
const { data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
|
||||
uploadedBunnyVideoId = bunnyVideoId;
|
||||
uploadedBunnyUploadToken = uploadToken;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
setNewVersionUploadStatus('Uploading video...');
|
||||
const upload = new tus.Upload(newVersionFile, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: signature,
|
||||
AuthorizationExpire: expirationTime.toString(),
|
||||
VideoId: bunnyVideoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: newVersionFile.type,
|
||||
title,
|
||||
},
|
||||
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
||||
setNewVersionUploadProgress(Number(percentage));
|
||||
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setNewVersionUploadStatus('Processing video...');
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
upload.start();
|
||||
});
|
||||
|
||||
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
|
||||
finalProviderId = 'bunny';
|
||||
finalProviderVideoId = bunnyVideoId;
|
||||
finalThumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${bunnyVideoId}/thumbnail.jpg`;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoUrl: finalVideoUrl,
|
||||
providerId: finalProviderId,
|
||||
providerVideoId: finalProviderVideoId,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
versionLabel: newVersionLabel.trim() || null,
|
||||
thumbnailUrl: finalThumbnailUrl,
|
||||
duration: finalDuration,
|
||||
setActive: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Failed to create version');
|
||||
}
|
||||
|
||||
const versionData = await res.json();
|
||||
const newVersion = versionData.data;
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const updatedVersions = prev.versions.map((v) => ({ ...v, isActive: false }));
|
||||
updatedVersions.unshift({
|
||||
...newVersion,
|
||||
comments: [],
|
||||
});
|
||||
return { ...prev, versions: updatedVersions };
|
||||
});
|
||||
setActiveVersionId(newVersion.id);
|
||||
setShowVersionDialog(false);
|
||||
setNewVersionUrl('');
|
||||
setNewVersionLabel('');
|
||||
setNewVersionSource(null);
|
||||
setNewVersionFile(null);
|
||||
setNewVersionUploadStatus('');
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: uploadedBunnyVideoId, uploadToken: uploadedBunnyUploadToken }),
|
||||
}).catch((cleanupError) => {
|
||||
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
||||
});
|
||||
}
|
||||
console.error('Failed to create version:', errorObj);
|
||||
toast.error(errorObj.message || 'Failed to create version');
|
||||
} finally {
|
||||
setIsCreatingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteVersion = async () => {
|
||||
if (!versionToDelete || !projectId) return;
|
||||
setIsDeletingVersion(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/videos/${videoId}/versions/${versionToDelete}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (res.ok) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
return { ...prev, versions: remaining };
|
||||
});
|
||||
|
||||
if (activeVersionId === versionToDelete) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
if (remaining.length > 0) {
|
||||
setActiveVersionId(remaining[0].id);
|
||||
} else {
|
||||
setActiveVersionId(null);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
setShowDeleteVersionDialog(false);
|
||||
setVersionToDelete(null);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || 'Failed to delete version');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete version');
|
||||
} finally {
|
||||
setIsDeletingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showVersionDialog,
|
||||
setShowVersionDialog,
|
||||
newVersionUrl,
|
||||
newVersionLabel,
|
||||
setNewVersionLabel,
|
||||
newVersionSource,
|
||||
newVersionUrlError,
|
||||
isCreatingVersion,
|
||||
newVersionMode,
|
||||
setNewVersionMode,
|
||||
newVersionFile,
|
||||
setNewVersionFile,
|
||||
newVersionUploadProgress,
|
||||
newVersionUploadStatus,
|
||||
handleNewVersionUrlChange,
|
||||
handleCreateVersion,
|
||||
|
||||
showDeleteVersionDialog,
|
||||
setShowDeleteVersionDialog,
|
||||
versionToDelete,
|
||||
setVersionToDelete,
|
||||
isDeletingVersion,
|
||||
handleDeleteVersion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { VideoData } from '@/components/video-page/types';
|
||||
|
||||
interface UseVersionDurationSyncParams {
|
||||
videoDuration: number;
|
||||
activeVersionDuration?: number | null;
|
||||
activeVersionId: string | null;
|
||||
propProjectId?: string;
|
||||
videoId: string;
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
}
|
||||
|
||||
export function useVersionDurationSync({
|
||||
videoDuration,
|
||||
activeVersionDuration,
|
||||
activeVersionId,
|
||||
propProjectId,
|
||||
videoId,
|
||||
setVideo,
|
||||
}: UseVersionDurationSyncParams) {
|
||||
useEffect(() => {
|
||||
if (!videoDuration || !activeVersionId || !propProjectId) return;
|
||||
if (activeVersionDuration && activeVersionDuration > 0) return;
|
||||
|
||||
const roundedDuration = Math.round(videoDuration);
|
||||
fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ duration: roundedDuration }),
|
||||
}).catch(() => {
|
||||
// ignore save errors
|
||||
});
|
||||
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
versions: prev.versions.map((v) =>
|
||||
v.id === activeVersionId ? { ...v, duration: roundedDuration } : v
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [videoDuration, activeVersionDuration, activeVersionId, propProjectId, videoId, setVideo]);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Comment, CommentTag, Version, VideoData } from '@/components/video-page/types';
|
||||
|
||||
interface UseVideoPageDataParams {
|
||||
mode: 'dashboard' | 'watch';
|
||||
videoId: string;
|
||||
propProjectId?: string;
|
||||
}
|
||||
|
||||
export function useVideoPageData({
|
||||
mode,
|
||||
videoId,
|
||||
propProjectId,
|
||||
}: UseVideoPageDataParams) {
|
||||
const [video, setVideo] = useState<VideoData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
|
||||
const [availableTags, setAvailableTags] = useState<CommentTag[]>([]);
|
||||
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
|
||||
|
||||
const commentsEtagRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const apiBasePath = useMemo(() => {
|
||||
return mode === 'dashboard'
|
||||
? `/api/projects/${propProjectId}/videos/${videoId}?includeComments=false`
|
||||
: `/api/watch/${videoId}`;
|
||||
}, [mode, propProjectId, videoId]);
|
||||
|
||||
const projectId = propProjectId || video?.projectId;
|
||||
|
||||
const fetchVersionComments = useCallback(async (versionId: string, useEtag: boolean) => {
|
||||
const headers: HeadersInit = {};
|
||||
if (useEtag) {
|
||||
const etag = commentsEtagRef.current.get(versionId);
|
||||
if (etag) headers['If-None-Match'] = etag;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/versions/${versionId}/comments?includeResolved=true`, {
|
||||
cache: 'no-store',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (res.status === 304) return;
|
||||
if (!res.ok) return;
|
||||
|
||||
const etag = res.headers.get('etag');
|
||||
if (etag) commentsEtagRef.current.set(versionId, etag);
|
||||
|
||||
const payload = await res.json();
|
||||
const commentsList = payload?.data?.comments;
|
||||
if (!Array.isArray(commentsList)) return;
|
||||
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const totalComments = commentsList.reduce((sum: number, comment: Comment) => {
|
||||
return sum + 1 + (comment.replies?.length ?? 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
versions: prev.versions.map((version) => (
|
||||
version.id === versionId
|
||||
? { ...version, comments: commentsList, _count: { comments: totalComments } }
|
||||
: version
|
||||
)),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchVideo() {
|
||||
try {
|
||||
const res = await fetch(apiBasePath, { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
const errorText = mode === 'dashboard' ? await res.text() : '';
|
||||
setError(mode === 'dashboard'
|
||||
? `Failed to load video: ${res.status} ${errorText}`
|
||||
: 'Video not found or access denied'
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const response = await res.json();
|
||||
const rawData = response.data as Omit<VideoData, 'versions'> & {
|
||||
versions?: Array<Version & { comments?: Comment[] }>;
|
||||
};
|
||||
const normalizedData: VideoData = {
|
||||
...rawData,
|
||||
versions: (rawData.versions || []).map((version) => ({
|
||||
...version,
|
||||
comments: Array.isArray(version.comments) ? version.comments : [],
|
||||
})),
|
||||
};
|
||||
|
||||
setVideo(normalizedData);
|
||||
const active = normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0];
|
||||
if (active) setActiveVersionId(active.id);
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
setError('Failed to load video');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
void fetchVideo();
|
||||
}, [apiBasePath, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeVersionId) return;
|
||||
void fetchVersionComments(activeVersionId, true);
|
||||
}, [activeVersionId, fetchVersionComments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
async function fetchTags() {
|
||||
try {
|
||||
const query = videoId ? `?videoId=${encodeURIComponent(videoId)}` : '';
|
||||
const res = await fetch(`/api/projects/${projectId}/tags${query}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const tags = data.data || [];
|
||||
setAvailableTags(tags);
|
||||
if (tags.length > 0 && !selectedTagId) {
|
||||
setSelectedTagId(tags[0].id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
void fetchTags();
|
||||
}, [projectId, selectedTagId, videoId]);
|
||||
|
||||
return {
|
||||
video,
|
||||
setVideo,
|
||||
loading,
|
||||
error,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
availableTags,
|
||||
setAvailableTags,
|
||||
selectedTagId,
|
||||
setSelectedTagId,
|
||||
projectId,
|
||||
fetchVersionComments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
'use client';
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
import Hls, { type Level } from 'hls.js';
|
||||
import { toast } from 'sonner';
|
||||
import type { AnnotationStroke } from '@/components/annotation-canvas';
|
||||
import type {
|
||||
BunnyPlaybackState,
|
||||
BunnyQualityOption,
|
||||
PlayerAdapter,
|
||||
Version,
|
||||
} from '@/components/video-page/types';
|
||||
|
||||
interface UseVideoPlayerParams {
|
||||
activeVersion: Version | undefined;
|
||||
activeVersionId: string | null;
|
||||
activeProviderId: string | undefined;
|
||||
embedUrl: string;
|
||||
canInitializePlayer: boolean;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
bunnyViewportRef: RefObject<HTMLDivElement | null>;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
|
||||
formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string;
|
||||
speedOptions: number[];
|
||||
scheduleWatchProgressSaveRef: RefObject<(input: {
|
||||
progress: number;
|
||||
duration?: number;
|
||||
immediate?: boolean;
|
||||
force?: boolean;
|
||||
}) => void>;
|
||||
setViewingAnnotation: (strokes: AnnotationStroke[] | null) => void;
|
||||
}
|
||||
|
||||
export function useVideoPlayer({
|
||||
activeVersion,
|
||||
activeVersionId,
|
||||
activeProviderId,
|
||||
embedUrl,
|
||||
canInitializePlayer,
|
||||
iframeRef,
|
||||
videoRef,
|
||||
bunnyViewportRef,
|
||||
timelineRef,
|
||||
hlsRef,
|
||||
playerRef,
|
||||
formatBunnyQualityLabel,
|
||||
speedOptions,
|
||||
scheduleWatchProgressSaveRef,
|
||||
setViewingAnnotation,
|
||||
}: UseVideoPlayerParams) {
|
||||
const [isApiLoaded, setIsApiLoaded] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
|
||||
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
||||
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
|
||||
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
|
||||
const [cursorIdle, setCursorIdle] = useState(false);
|
||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isFullscreenMode, setIsFullscreenMode] = useState(false);
|
||||
const [showComments, setShowComments] = useState(true);
|
||||
const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
isDraggingRef.current = isDragging;
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewportEl = bunnyViewportRef.current;
|
||||
if (!viewportEl || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const updateFrameWidth = () => {
|
||||
const viewportWidth = viewportEl.clientWidth;
|
||||
const viewportHeight = viewportEl.clientHeight;
|
||||
if (viewportWidth <= 0 || viewportHeight <= 0) return;
|
||||
setBunnyPortraitFrameWidth(Math.min(viewportWidth, viewportHeight * (9 / 16)));
|
||||
};
|
||||
|
||||
updateFrameWidth();
|
||||
const observer = new ResizeObserver(updateFrameWidth);
|
||||
observer.observe(viewportEl);
|
||||
return () => observer.disconnect();
|
||||
}, [activeVersionId, bunnyViewportRef]);
|
||||
|
||||
const handleVideoMouseMove = useCallback(() => {
|
||||
setCursorIdle(false);
|
||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||
|
||||
const shouldHideControls = isFullscreenMode;
|
||||
|
||||
if (isPlaying || shouldHideControls) {
|
||||
cursorIdleTimerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, 1000);
|
||||
}
|
||||
}, [isFullscreenMode, isPlaying]);
|
||||
|
||||
const handleVideoMouseLeave = useCallback(() => {
|
||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||
setCursorIdle(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isApiLoaded) return;
|
||||
|
||||
if (window.YT) {
|
||||
setIsApiLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://www.youtube.com/iframe_api';
|
||||
const firstScriptTag = document.getElementsByTagName('script')[0];
|
||||
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
|
||||
|
||||
window.onYouTubeIframeAPIReady = () => {
|
||||
setIsApiLoaded(true);
|
||||
};
|
||||
}, [isApiLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canInitializePlayer) return;
|
||||
if (!activeProviderId) return;
|
||||
const isYoutube = activeProviderId === 'youtube';
|
||||
const isBunny = activeProviderId === 'bunny';
|
||||
|
||||
if (isYoutube && !isApiLoaded) return;
|
||||
if (!isYoutube && !isBunny) return;
|
||||
|
||||
setIsReady(false);
|
||||
setBunnyPlaybackState('none');
|
||||
setCurrentTime(0);
|
||||
setVideoDuration(0);
|
||||
setIsPlaying(false);
|
||||
setIsMuted(false);
|
||||
setPlaybackSpeed(1);
|
||||
setQualityOptions([]);
|
||||
setSelectedQualityLevel(-1);
|
||||
setIsBunnyPortraitSource(false);
|
||||
|
||||
if (playerRef.current) {
|
||||
try { playerRef.current.destroy(); } catch { /* ignore */ }
|
||||
playerRef.current = null;
|
||||
}
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (bunnyRetryTimerRef.current) {
|
||||
clearTimeout(bunnyRetryTimerRef.current);
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
|
||||
const initPlayer = () => {
|
||||
if (isYoutube) {
|
||||
if (!iframeRef.current) return;
|
||||
playerRef.current = new YT.Player(iframeRef.current, {
|
||||
events: {
|
||||
onReady: (event: YT.PlayerEvent) => {
|
||||
setIsReady(true);
|
||||
const dur = event.target.getDuration();
|
||||
if (dur > 0) setVideoDuration(dur);
|
||||
},
|
||||
onStateChange: (event: YT.OnStateChangeEvent) => {
|
||||
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
||||
|
||||
if (event.data === YT.PlayerState.PAUSED) {
|
||||
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
||||
const playerDuration = playerRef.current?.getDuration?.() || 0;
|
||||
scheduleWatchProgressSaveRef.current({
|
||||
progress: playerCurrentTime,
|
||||
duration: playerDuration,
|
||||
immediate: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.data === YT.PlayerState.PLAYING) {
|
||||
const dur = event.target.getDuration();
|
||||
if (dur > 0) setVideoDuration(dur);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (isBunny) {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
let cachedDuration = 0;
|
||||
let destroyed = false;
|
||||
let retryAttempt = 0;
|
||||
let usingHlsJs = false;
|
||||
let hlsInstance: Hls | null = null;
|
||||
const clearRetryTimer = () => {
|
||||
if (bunnyRetryTimerRef.current) {
|
||||
clearTimeout(bunnyRetryTimerRef.current);
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleRetry = (retryFn: () => void) => {
|
||||
clearRetryTimer();
|
||||
bunnyRetryTimerRef.current = setTimeout(() => {
|
||||
if (!destroyed) {
|
||||
retryFn();
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
const getRetryUrl = () => {
|
||||
retryAttempt += 1;
|
||||
const separator = embedUrl.includes('?') ? '&' : '?';
|
||||
return `${embedUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
|
||||
};
|
||||
const retryNativeLoad = () => {
|
||||
videoEl.src = getRetryUrl();
|
||||
videoEl.load();
|
||||
};
|
||||
const retryHlsLoad = () => {
|
||||
if (destroyed || !hlsInstance) return;
|
||||
const retryUrl = getRetryUrl();
|
||||
try {
|
||||
hlsInstance.stopLoad();
|
||||
} catch {
|
||||
// ignore stop-load failures and continue with a fresh loadSource
|
||||
}
|
||||
hlsInstance.loadSource(retryUrl);
|
||||
hlsInstance.startLoad(-1);
|
||||
};
|
||||
|
||||
const syncDuration = () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
setVideoDuration(videoEl.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProgress = () => {
|
||||
const current = videoEl.currentTime || 0;
|
||||
const duration = Number.isFinite(videoEl.duration) && videoEl.duration > 0 ? videoEl.duration : cachedDuration;
|
||||
scheduleWatchProgressSaveRef.current({
|
||||
progress: current,
|
||||
duration,
|
||||
immediate: true,
|
||||
force: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (destroyed) return;
|
||||
clearRetryTimer();
|
||||
setBunnyPlaybackState('none');
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
setIsReady(true);
|
||||
syncDuration();
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
setIsPlaying(true);
|
||||
setBunnyPlaybackState('none');
|
||||
syncDuration();
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
setIsPlaying(false);
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!isDraggingRef.current) {
|
||||
setCurrentTime(videoEl.currentTime || 0);
|
||||
}
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0 && videoEl.duration !== cachedDuration) {
|
||||
cachedDuration = videoEl.duration;
|
||||
setVideoDuration(videoEl.duration);
|
||||
}
|
||||
};
|
||||
const onVideoError = () => {
|
||||
if (destroyed) return;
|
||||
if (usingHlsJs) return;
|
||||
if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
setBunnyPlaybackState('error');
|
||||
return;
|
||||
}
|
||||
setIsReady(false);
|
||||
setBunnyPlaybackState('processing');
|
||||
scheduleRetry(retryNativeLoad);
|
||||
};
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
videoEl.addEventListener('pause', onPause);
|
||||
videoEl.addEventListener('ended', onEnded);
|
||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.addEventListener('error', onVideoError);
|
||||
|
||||
const configureHlsLevels = (levels: Level[]) => {
|
||||
setQualityOptions(levels.map((level, index) => ({
|
||||
level: index,
|
||||
label: formatBunnyQualityLabel(level, index),
|
||||
})));
|
||||
setSelectedQualityLevel(-1);
|
||||
};
|
||||
|
||||
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoEl.src = embedUrl;
|
||||
videoEl.load();
|
||||
} else if (Hls.isSupported()) {
|
||||
usingHlsJs = true;
|
||||
const hls = new Hls();
|
||||
hlsInstance = hls;
|
||||
hlsRef.current = hls;
|
||||
hls.attachMedia(videoEl);
|
||||
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
if (!destroyed) {
|
||||
hls.loadSource(embedUrl);
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
if (destroyed) return;
|
||||
clearRetryTimer();
|
||||
setBunnyPlaybackState('none');
|
||||
configureHlsLevels(data.levels);
|
||||
setIsReady(true);
|
||||
syncDuration();
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (destroyed) return;
|
||||
const responseCode = (data as { response?: { code?: number } }).response?.code;
|
||||
const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR
|
||||
|| data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
||||
const hasProcessingLikeStatus = responseCode === undefined
|
||||
|| responseCode === 0
|
||||
|| responseCode === 403
|
||||
|| responseCode === 404
|
||||
|| responseCode === 423
|
||||
|| responseCode === 429
|
||||
|| responseCode === 503;
|
||||
const isLikelyProcessing = isManifestLoadFailure
|
||||
&& hasProcessingLikeStatus;
|
||||
const isNetworkPreMetadataProcessing = data.type === Hls.ErrorTypes.NETWORK_ERROR
|
||||
&& hasProcessingLikeStatus
|
||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
const isUnknownPreMetadataProcessing = !data.details
|
||||
&& !data.type
|
||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
|
||||
setIsReady(false);
|
||||
setBunnyPlaybackState('processing');
|
||||
scheduleRetry(retryHlsLoad);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.fatal) {
|
||||
setBunnyPlaybackState('error');
|
||||
console.error('Fatal HLS error:', data);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setBunnyPlaybackState('error');
|
||||
console.error('HLS is not supported in this browser.');
|
||||
}
|
||||
|
||||
playerRef.current = {
|
||||
playVideo: () => {
|
||||
videoEl.play().catch((err) => console.error('Error playing Bunny video:', err));
|
||||
},
|
||||
pauseVideo: () => videoEl.pause(),
|
||||
seekTo: (time: number) => {
|
||||
videoEl.currentTime = time;
|
||||
},
|
||||
mute: () => {
|
||||
videoEl.muted = true;
|
||||
},
|
||||
unMute: () => {
|
||||
videoEl.muted = false;
|
||||
},
|
||||
isMuted: () => videoEl.muted,
|
||||
getCurrentTime: () => videoEl.currentTime || 0,
|
||||
getDuration: () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () => (
|
||||
videoEl.paused
|
||||
? (window.YT?.PlayerState?.PAUSED ?? 2)
|
||||
: (window.YT?.PlayerState?.PLAYING ?? 1)
|
||||
),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
clearRetryTimer();
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
videoEl.removeEventListener('ended', onEnded);
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.removeEventListener('error', onVideoError);
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
hlsRef.current = null;
|
||||
}
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (isYoutube) {
|
||||
if (window.YT?.Player) {
|
||||
initPlayer();
|
||||
} else {
|
||||
window.onYouTubeIframeAPIReady = initPlayer;
|
||||
}
|
||||
} else if (isBunny) {
|
||||
initPlayer();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (isYoutube) {
|
||||
window.onYouTubeIframeAPIReady = undefined;
|
||||
}
|
||||
if (playerRef.current) {
|
||||
try { playerRef.current.destroy(); } catch { /* ignore */ }
|
||||
playerRef.current = null;
|
||||
}
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (bunnyRetryTimerRef.current) {
|
||||
clearTimeout(bunnyRetryTimerRef.current);
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().then(() => {
|
||||
setIsFullscreenMode(true);
|
||||
setShowComments(false);
|
||||
}).catch((err) => {
|
||||
console.error('Fullscreen failed:', err);
|
||||
toast.error('Unable to enter fullscreen mode');
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => {
|
||||
setIsFullscreenMode(false);
|
||||
setShowComments(true);
|
||||
}).catch((err) => {
|
||||
console.error('Exit fullscreen failed:', err);
|
||||
toast.error('Unable to exit fullscreen mode');
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
const isCurrentlyFullscreen = !!document.fullscreenElement;
|
||||
setIsFullscreenMode(isCurrentlyFullscreen);
|
||||
if (isCurrentlyFullscreen) {
|
||||
setShowComments(false);
|
||||
} else {
|
||||
setShowComments(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !playerRef.current) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!isDragging && playerRef.current) {
|
||||
if (playerRef.current.getCurrentTime) {
|
||||
setCurrentTime(playerRef.current.getCurrentTime());
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isReady, isDragging, activeVersion?.providerId, playerRef]);
|
||||
|
||||
const duration = useMemo(() => {
|
||||
return videoDuration || activeVersion?.duration || 0;
|
||||
}, [videoDuration, activeVersion?.duration]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
const isBunnyBlocked = activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none';
|
||||
const isPlaybackControlKey = [
|
||||
'Space',
|
||||
'KeyK',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'Comma',
|
||||
'Period',
|
||||
'KeyM',
|
||||
'KeyJ',
|
||||
'KeyL',
|
||||
].includes(e.code);
|
||||
if (isBunnyBlocked && isPlaybackControlKey) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
case 'KeyK':
|
||||
e.preventDefault();
|
||||
if (playerRef.current) {
|
||||
if (isPlaying) {
|
||||
playerRef.current.pauseVideo();
|
||||
} else {
|
||||
playerRef.current.playVideo();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (playerRef.current) {
|
||||
const newTime = Math.max(0, currentTime - 5);
|
||||
if (playerRef.current.seekTo) {
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
}
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (playerRef.current) {
|
||||
const newTime = Math.min(duration, currentTime + 5);
|
||||
if (playerRef.current.seekTo) {
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
}
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
{
|
||||
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
||||
if (currentIndex < speedOptions.length - 1) {
|
||||
const newSpeed = speedOptions[currentIndex + 1];
|
||||
setPlaybackSpeed(newSpeed);
|
||||
playerRef.current?.setPlaybackRate(newSpeed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
{
|
||||
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
||||
if (currentIndex > 0) {
|
||||
const newSpeed = speedOptions[currentIndex - 1];
|
||||
setPlaybackSpeed(newSpeed);
|
||||
playerRef.current?.setPlaybackRate(newSpeed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Comma':
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
||||
if (currentIndex > 0) {
|
||||
const newSpeed = speedOptions[currentIndex - 1];
|
||||
setPlaybackSpeed(newSpeed);
|
||||
playerRef.current?.setPlaybackRate(newSpeed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Period':
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const currentIndex = speedOptions.indexOf(playbackSpeed);
|
||||
if (currentIndex < speedOptions.length - 1) {
|
||||
const newSpeed = speedOptions[currentIndex + 1];
|
||||
setPlaybackSpeed(newSpeed);
|
||||
playerRef.current?.setPlaybackRate(newSpeed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'KeyM':
|
||||
e.preventDefault();
|
||||
if (playerRef.current) {
|
||||
if (isMuted) {
|
||||
playerRef.current.unMute();
|
||||
} else {
|
||||
playerRef.current.mute();
|
||||
}
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
break;
|
||||
case 'KeyJ':
|
||||
e.preventDefault();
|
||||
if (playerRef.current?.seekTo) {
|
||||
const newTime = Math.max(0, currentTime - 10);
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'KeyL':
|
||||
e.preventDefault();
|
||||
if (playerRef.current?.seekTo) {
|
||||
const newTime = Math.min(duration, currentTime + 10);
|
||||
playerRef.current.seekTo(newTime, true);
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
break;
|
||||
case 'KeyF':
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none') return;
|
||||
if (!playerRef.current) return;
|
||||
if (isPlaying) {
|
||||
playerRef.current.pauseVideo();
|
||||
} else {
|
||||
playerRef.current.playVideo();
|
||||
}
|
||||
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, playerRef]);
|
||||
|
||||
const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => {
|
||||
setCurrentTime(timestamp);
|
||||
if (playerRef.current?.seekTo) {
|
||||
const playerState = playerRef.current.getPlayerState?.();
|
||||
const ytPlayingState = window.YT?.PlayerState?.PLAYING ?? 1;
|
||||
const ytBufferingState = window.YT?.PlayerState?.BUFFERING ?? 3;
|
||||
const wasPlayingBeforeSeek = typeof playerState === 'number'
|
||||
? playerState === ytPlayingState || playerState === ytBufferingState
|
||||
: isPlaying;
|
||||
|
||||
playerRef.current.seekTo(timestamp, true);
|
||||
if (wasPlayingBeforeSeek) {
|
||||
playerRef.current.playVideo();
|
||||
} else {
|
||||
playerRef.current.pauseVideo();
|
||||
}
|
||||
}
|
||||
if (annotation) {
|
||||
try {
|
||||
const strokes = JSON.parse(annotation) as AnnotationStroke[];
|
||||
setViewingAnnotation(strokes);
|
||||
} catch {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
} else {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
}, [isPlaying, playerRef, setViewingAnnotation]);
|
||||
|
||||
const handleMuteToggle = useCallback(() => {
|
||||
if (!playerRef.current) return;
|
||||
if (isMuted) {
|
||||
playerRef.current.unMute();
|
||||
} else {
|
||||
playerRef.current.mute();
|
||||
}
|
||||
setIsMuted(!isMuted);
|
||||
}, [isMuted, playerRef]);
|
||||
|
||||
const handleSkip = useCallback(
|
||||
(seconds: number) => {
|
||||
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
||||
handleSeekToTimestamp(newTime);
|
||||
},
|
||||
[currentTime, duration, handleSeekToTimestamp]
|
||||
);
|
||||
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
setPlaybackSpeed(speed);
|
||||
playerRef.current?.setPlaybackRate(speed);
|
||||
},
|
||||
[playerRef]
|
||||
);
|
||||
|
||||
const handleQualityChange = useCallback((level: number) => {
|
||||
const hls = hlsRef.current;
|
||||
if (!hls) return;
|
||||
|
||||
if (level === -1) {
|
||||
hls.currentLevel = -1;
|
||||
hls.nextLevel = -1;
|
||||
setSelectedQualityLevel(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
hls.currentLevel = level;
|
||||
hls.nextLevel = level;
|
||||
setSelectedQualityLevel(level);
|
||||
}, [hlsRef]);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!timelineRef.current) return;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||
const newTime = percentage * duration;
|
||||
handleSeekToTimestamp(newTime);
|
||||
},
|
||||
[duration, handleSeekToTimestamp, timelineRef]
|
||||
);
|
||||
|
||||
const handleTimelineMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true);
|
||||
handleTimelineClick(e);
|
||||
},
|
||||
[handleTimelineClick]
|
||||
);
|
||||
|
||||
const handleTimelineMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !timelineRef.current) return;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||
setCurrentTime(percentage * duration);
|
||||
},
|
||||
[isDragging, duration, timelineRef]
|
||||
);
|
||||
|
||||
const handleTimelineMouseUp = useCallback(() => {
|
||||
if (isDragging) {
|
||||
handleSeekToTimestamp(currentTime);
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, [isDragging, currentTime, handleSeekToTimestamp]);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
bunnyPlaybackState,
|
||||
currentTime,
|
||||
setCurrentTime,
|
||||
videoDuration,
|
||||
setVideoDuration,
|
||||
isPlaying,
|
||||
isMuted,
|
||||
isDragging,
|
||||
playbackSpeed,
|
||||
qualityOptions,
|
||||
selectedQualityLevel,
|
||||
isBunnyPortraitSource,
|
||||
bunnyPortraitFrameWidth,
|
||||
cursorIdle,
|
||||
isFullscreenMode,
|
||||
showComments,
|
||||
isMobileCommentsOpen,
|
||||
setShowComments,
|
||||
setIsMobileCommentsOpen,
|
||||
handleVideoMouseMove,
|
||||
handleVideoMouseLeave,
|
||||
handlePlayPause,
|
||||
handleSeekToTimestamp,
|
||||
handleMuteToggle,
|
||||
handleSkip,
|
||||
handleSpeedChange,
|
||||
handleQualityChange,
|
||||
handleTimelineMouseDown,
|
||||
handleTimelineMouseMove,
|
||||
handleTimelineMouseUp,
|
||||
toggleFullscreen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import type { PlayerAdapter, WatchProgressConfig } from '@/components/video-page/types';
|
||||
|
||||
interface UseWatchProgressParams extends WatchProgressConfig {
|
||||
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
|
||||
isReady: boolean;
|
||||
currentTime: number;
|
||||
videoDuration: number;
|
||||
}
|
||||
|
||||
export function useWatchProgress({
|
||||
videoId,
|
||||
activeVersionId,
|
||||
isAuthenticated,
|
||||
pathname,
|
||||
playerRef,
|
||||
isReady,
|
||||
currentTime,
|
||||
videoDuration,
|
||||
}: UseWatchProgressParams) {
|
||||
const [savedProgress, setSavedProgress] = useState<number | null>(null);
|
||||
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||
const [progressFetchKey, setProgressFetchKey] = useState(0);
|
||||
|
||||
const videoDurationRef = useRef(0);
|
||||
const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const progressDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const progressWriteInFlightRef = useRef(false);
|
||||
const pendingProgressPayloadRef = useRef<{ progress: number; duration: number; force: boolean } | null>(null);
|
||||
const lastSavedProgressRef = useRef<number>(0);
|
||||
const lastPathnameRef = useRef<string>(pathname);
|
||||
|
||||
const flushScheduledWatchProgress = useCallback(async () => {
|
||||
if (!isAuthenticated || !activeVersionId || progressWriteInFlightRef.current) return;
|
||||
|
||||
const nextPayload = pendingProgressPayloadRef.current;
|
||||
if (!nextPayload) return;
|
||||
|
||||
if (!nextPayload.force && Math.abs(nextPayload.progress - lastSavedProgressRef.current) < 2) {
|
||||
pendingProgressPayloadRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingProgressPayloadRef.current = null;
|
||||
progressWriteInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/watch/${videoId}/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
progress: nextPayload.progress,
|
||||
duration: nextPayload.duration,
|
||||
versionId: activeVersionId,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
lastSavedProgressRef.current = nextPayload.progress;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving watch progress:', err);
|
||||
} finally {
|
||||
progressWriteInFlightRef.current = false;
|
||||
if (pendingProgressPayloadRef.current) {
|
||||
void flushScheduledWatchProgress();
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, activeVersionId, videoId]);
|
||||
|
||||
const scheduleWatchProgressSave = useCallback((input: {
|
||||
progress: number;
|
||||
duration?: number;
|
||||
immediate?: boolean;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
if (!isAuthenticated || !activeVersionId) return;
|
||||
|
||||
const progress = Math.max(0, input.progress);
|
||||
if (progress <= 0) return;
|
||||
|
||||
const duration = Math.max(0, input.duration ?? videoDurationRef.current ?? 0);
|
||||
const force = input.force ?? false;
|
||||
|
||||
if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPayload = pendingProgressPayloadRef.current;
|
||||
pendingProgressPayloadRef.current = existingPayload
|
||||
? {
|
||||
progress: Math.max(existingPayload.progress, progress),
|
||||
duration: Math.max(existingPayload.duration, duration),
|
||||
force: existingPayload.force || force,
|
||||
}
|
||||
: { progress, duration, force };
|
||||
|
||||
if (input.immediate) {
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
progressDebounceTimerRef.current = null;
|
||||
}
|
||||
void flushScheduledWatchProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
}
|
||||
|
||||
progressDebounceTimerRef.current = setTimeout(() => {
|
||||
progressDebounceTimerRef.current = null;
|
||||
void flushScheduledWatchProgress();
|
||||
}, 800);
|
||||
}, [isAuthenticated, activeVersionId, flushScheduledWatchProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
progressDebounceTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
videoDurationRef.current = videoDuration;
|
||||
}, [videoDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
lastSavedProgressRef.current = 0;
|
||||
pendingProgressPayloadRef.current = null;
|
||||
progressWriteInFlightRef.current = false;
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
progressDebounceTimerRef.current = null;
|
||||
}
|
||||
}, [videoId, activeVersionId]);
|
||||
|
||||
const loadWatchProgress = useCallback(async (showPrompt = true) => {
|
||||
if (!isAuthenticated || !activeVersionId) return;
|
||||
|
||||
setSavedProgress(null);
|
||||
setShowResumePrompt(false);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const response = await res.json();
|
||||
const progress = response.data?.progress || 0;
|
||||
const percentage = response.data?.percentage || 0;
|
||||
|
||||
if (showPrompt && percentage > 5 && percentage < 95) {
|
||||
setSavedProgress(progress);
|
||||
setShowResumePrompt(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading watch progress:', err);
|
||||
}
|
||||
}, [isAuthenticated, activeVersionId, videoId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWatchProgress();
|
||||
}, [loadWatchProgress, progressFetchKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastPathnameRef.current !== pathname) {
|
||||
const previousPath = lastPathnameRef.current;
|
||||
lastPathnameRef.current = pathname;
|
||||
|
||||
if (previousPath !== pathname) {
|
||||
setProgressFetchKey((k) => k + 1);
|
||||
}
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !isReady || !activeVersionId) return;
|
||||
|
||||
progressSaveTimerRef.current = setInterval(() => {
|
||||
if (playerRef.current?.getCurrentTime) {
|
||||
scheduleWatchProgressSave({
|
||||
progress: playerRef.current.getCurrentTime(),
|
||||
duration: playerRef.current.getDuration?.() || videoDuration,
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (progressSaveTimerRef.current) {
|
||||
clearInterval(progressSaveTimerRef.current);
|
||||
progressSaveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, isReady, videoDuration, activeVersionId, scheduleWatchProgressSave, playerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const saveProgressOnLeave = () => {
|
||||
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || currentTime;
|
||||
const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
|
||||
const pendingPayload = pendingProgressPayloadRef.current;
|
||||
const finalProgress = Math.max(playerCurrentTime, pendingPayload?.progress ?? 0);
|
||||
const finalDuration = Math.max(playerDuration, pendingPayload?.duration ?? 0);
|
||||
|
||||
if (finalProgress > 0 && navigator.sendBeacon && activeVersionId) {
|
||||
if (progressDebounceTimerRef.current) {
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
progressDebounceTimerRef.current = null;
|
||||
}
|
||||
const data = new Blob([JSON.stringify({
|
||||
progress: finalProgress,
|
||||
duration: finalDuration,
|
||||
versionId: activeVersionId,
|
||||
})], { type: 'application/json' });
|
||||
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
||||
const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
|
||||
|
||||
if (document.visibilityState === 'hidden') {
|
||||
scheduleWatchProgressSave({
|
||||
progress: playerCurrentTime,
|
||||
duration: playerDuration,
|
||||
immediate: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', saveProgressOnLeave);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', saveProgressOnLeave);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [isAuthenticated, currentTime, videoDuration, activeVersionId, videoId, scheduleWatchProgressSave, playerRef]);
|
||||
|
||||
const handleResumeFromSaved = useCallback(() => {
|
||||
if (savedProgress !== null && playerRef.current) {
|
||||
if (playerRef.current.seekTo) {
|
||||
playerRef.current.seekTo(savedProgress, true);
|
||||
}
|
||||
setShowResumePrompt(false);
|
||||
setSavedProgress(null);
|
||||
return savedProgress;
|
||||
}
|
||||
return null;
|
||||
}, [savedProgress, playerRef]);
|
||||
|
||||
const handleDismissResume = useCallback(() => {
|
||||
setShowResumePrompt(false);
|
||||
setSavedProgress(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
savedProgress,
|
||||
showResumePrompt,
|
||||
scheduleWatchProgressSave,
|
||||
loadWatchProgress,
|
||||
handleResumeFromSaved,
|
||||
handleDismissResume,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user