mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
refactor: eslint and prettier conflict will be resolved and formatted
This commit is contained in:
@@ -50,7 +50,9 @@ export function useApprovals({ projectId, activeVersionId, currentUserId }: UseA
|
||||
setIsLoadingCandidates(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/approval-candidates`, { cache: 'no-store' });
|
||||
const res = await fetch(`/api/projects/${projectId}/approval-candidates`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(payload?.error || 'Failed to fetch approvers');
|
||||
@@ -64,80 +66,85 @@ export function useApprovals({ projectId, activeVersionId, currentUserId }: UseA
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const createRequest = useCallback(async (approverIds: string[], message?: string) => {
|
||||
if (!activeVersionId) return false;
|
||||
setIsSubmittingRequest(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approverIds, message: message || undefined }),
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(payload?.error || 'Failed to create approval request');
|
||||
const createRequest = useCallback(
|
||||
async (approverIds: string[], message?: string) => {
|
||||
if (!activeVersionId) return false;
|
||||
setIsSubmittingRequest(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approverIds, message: message || undefined }),
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(payload?.error || 'Failed to create approval request');
|
||||
return false;
|
||||
}
|
||||
await fetchRequests();
|
||||
return true;
|
||||
} catch {
|
||||
setError('Failed to create approval request');
|
||||
return false;
|
||||
} finally {
|
||||
setIsSubmittingRequest(false);
|
||||
}
|
||||
await fetchRequests();
|
||||
return true;
|
||||
} catch {
|
||||
setError('Failed to create approval request');
|
||||
return false;
|
||||
} finally {
|
||||
setIsSubmittingRequest(false);
|
||||
}
|
||||
}, [activeVersionId, fetchRequests]);
|
||||
},
|
||||
[activeVersionId, fetchRequests]
|
||||
);
|
||||
|
||||
const submitDecision = useCallback(async (
|
||||
requestId: string,
|
||||
decision: 'APPROVED' | 'REJECTED',
|
||||
note?: string
|
||||
) => {
|
||||
setIsSubmittingDecision(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${requestId}/decision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decision, note: note || undefined }),
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(payload?.error || 'Failed to submit approval decision');
|
||||
const submitDecision = useCallback(
|
||||
async (requestId: string, decision: 'APPROVED' | 'REJECTED', note?: string) => {
|
||||
setIsSubmittingDecision(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${requestId}/decision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decision, note: note || undefined }),
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(payload?.error || 'Failed to submit approval decision');
|
||||
return false;
|
||||
}
|
||||
await fetchRequests();
|
||||
return true;
|
||||
} catch {
|
||||
setError('Failed to submit approval decision');
|
||||
return false;
|
||||
} finally {
|
||||
setIsSubmittingDecision(false);
|
||||
}
|
||||
await fetchRequests();
|
||||
return true;
|
||||
} catch {
|
||||
setError('Failed to submit approval decision');
|
||||
return false;
|
||||
} finally {
|
||||
setIsSubmittingDecision(false);
|
||||
}
|
||||
}, [fetchRequests]);
|
||||
},
|
||||
[fetchRequests]
|
||||
);
|
||||
|
||||
const cancelRequest = useCallback(async (requestId: string) => {
|
||||
setIsCancelingRequest(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${requestId}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(payload?.error || 'Failed to cancel approval request');
|
||||
const cancelRequest = useCallback(
|
||||
async (requestId: string) => {
|
||||
setIsCancelingRequest(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${requestId}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(payload?.error || 'Failed to cancel approval request');
|
||||
return false;
|
||||
}
|
||||
await fetchRequests();
|
||||
return true;
|
||||
} catch {
|
||||
setError('Failed to cancel approval request');
|
||||
return false;
|
||||
} finally {
|
||||
setIsCancelingRequest(false);
|
||||
}
|
||||
await fetchRequests();
|
||||
return true;
|
||||
} catch {
|
||||
setError('Failed to cancel approval request');
|
||||
return false;
|
||||
} finally {
|
||||
setIsCancelingRequest(false);
|
||||
}
|
||||
}, [fetchRequests]);
|
||||
},
|
||||
[fetchRequests]
|
||||
);
|
||||
|
||||
const activePendingRequest = useMemo(
|
||||
() => requests.find((request) => request.status === 'PENDING') || null,
|
||||
@@ -146,9 +153,11 @@ export function useApprovals({ projectId, activeVersionId, currentUserId }: UseA
|
||||
|
||||
const myPendingDecision = useMemo(() => {
|
||||
if (!currentUserId || !activePendingRequest) return null;
|
||||
return activePendingRequest.decisions.find(
|
||||
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
|
||||
) || null;
|
||||
return (
|
||||
activePendingRequest.decisions.find(
|
||||
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
|
||||
) || null
|
||||
);
|
||||
}, [activePendingRequest, currentUserId]);
|
||||
|
||||
return {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,9 +24,10 @@ export function useCommentMedia() {
|
||||
const tick = () => {
|
||||
const audio = audioPlayerRef.current;
|
||||
if (audio) {
|
||||
const dur = isFinite(audio.duration) && audio.duration > 0
|
||||
? audio.duration
|
||||
: voiceKnownDurationRef.current;
|
||||
const dur =
|
||||
isFinite(audio.duration) && audio.duration > 0
|
||||
? audio.duration
|
||||
: voiceKnownDurationRef.current;
|
||||
if (dur > 0) {
|
||||
setVoiceProgress((audio.currentTime / dur) * 100);
|
||||
setVoiceCurrentTime(audio.currentTime);
|
||||
@@ -37,54 +38,57 @@ export function useCommentMedia() {
|
||||
voiceRafRef.current = requestAnimationFrame(tick);
|
||||
}, [stopVoiceTracking]);
|
||||
|
||||
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
|
||||
if (playingVoiceId === commentId) {
|
||||
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();
|
||||
audioPlayerRef.current = null;
|
||||
}
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
|
||||
voiceKnownDurationRef.current = knownDuration || 0;
|
||||
const audio = new Audio(voiceUrl);
|
||||
audio.playbackRate = voicePlaybackRate;
|
||||
audioPlayerRef.current = audio;
|
||||
setPlayingVoiceId(commentId);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
}
|
||||
stopVoiceTracking();
|
||||
audio.onplay = () => {
|
||||
startVoiceTracking();
|
||||
};
|
||||
|
||||
voiceKnownDurationRef.current = knownDuration || 0;
|
||||
const audio = new Audio(voiceUrl);
|
||||
audio.playbackRate = voicePlaybackRate;
|
||||
audioPlayerRef.current = audio;
|
||||
setPlayingVoiceId(commentId);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
audio.onended = () => {
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
audioPlayerRef.current = null;
|
||||
};
|
||||
|
||||
audio.onplay = () => {
|
||||
startVoiceTracking();
|
||||
};
|
||||
audio.onerror = () => {
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
audioPlayerRef.current = null;
|
||||
};
|
||||
|
||||
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]);
|
||||
void audio.play();
|
||||
},
|
||||
[playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]
|
||||
);
|
||||
|
||||
const stopVoice = useCallback(() => {
|
||||
if (audioPlayerRef.current) {
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
|
||||
import type {
|
||||
BunnyDownloadPreference,
|
||||
Comment,
|
||||
DownloadTarget,
|
||||
Version,
|
||||
VideoData,
|
||||
} from '@/components/video-page/types';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
function sanitizeDownloadFileName(value: string): string {
|
||||
@@ -54,73 +60,80 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
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 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;
|
||||
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 (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);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
|
||||
} else {
|
||||
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
|
||||
if (!downloadUrl) {
|
||||
throw new Error('Direct download URL is not allowed');
|
||||
throw new Error('Missing download URL');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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]);
|
||||
},
|
||||
[activeVersion, video, isDownloadingVideo]
|
||||
);
|
||||
|
||||
return {
|
||||
activeDownloadTarget,
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
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 {
|
||||
parseVideoUrl,
|
||||
getThumbnailUrl,
|
||||
fetchVideoMetadata,
|
||||
type VideoSource,
|
||||
} from '@/lib/video-providers';
|
||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
@@ -94,7 +99,9 @@ export function useVersionActions({
|
||||
});
|
||||
|
||||
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||
const { data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
|
||||
const {
|
||||
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
uploadedBunnyVideoId = bunnyVideoId;
|
||||
uploadedBunnyUploadToken = uploadToken;
|
||||
|
||||
@@ -179,7 +186,10 @@ export function useVersionActions({
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: uploadedBunnyVideoId, uploadToken: uploadedBunnyUploadToken }),
|
||||
body: JSON.stringify({
|
||||
videoId: uploadedBunnyVideoId,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
}),
|
||||
}).catch((cleanupError) => {
|
||||
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
||||
});
|
||||
|
||||
@@ -64,45 +64,54 @@ export function useVideoAssets({
|
||||
const assetsEtagRef = useRef<string | null>(null);
|
||||
const isMutatingRef = useRef(false);
|
||||
|
||||
const fetchAssets = useCallback(async (options?: { useEtag?: boolean; silent?: boolean }) => {
|
||||
const useEtag = options?.useEtag ?? false;
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setIsLoadingAssets(true);
|
||||
try {
|
||||
const headers: HeadersInit = {};
|
||||
if (useEtag && assetsEtagRef.current) {
|
||||
headers['If-None-Match'] = assetsEtagRef.current;
|
||||
}
|
||||
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, { cache: 'no-store', headers });
|
||||
if (res.status === 304) return;
|
||||
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
|
||||
if (!res.ok) {
|
||||
if (!silent) {
|
||||
toast.error(payload?.error || 'Failed to fetch assets');
|
||||
const fetchAssets = useCallback(
|
||||
async (options?: { useEtag?: boolean; silent?: boolean }) => {
|
||||
const useEtag = options?.useEtag ?? false;
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setIsLoadingAssets(true);
|
||||
try {
|
||||
const headers: HeadersInit = {};
|
||||
if (useEtag && assetsEtagRef.current) {
|
||||
headers['If-None-Match'] = assetsEtagRef.current;
|
||||
}
|
||||
return;
|
||||
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, {
|
||||
cache: 'no-store',
|
||||
headers,
|
||||
});
|
||||
if (res.status === 304) return;
|
||||
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
|
||||
if (!res.ok) {
|
||||
if (!silent) {
|
||||
toast.error(payload?.error || 'Failed to fetch assets');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const etag = res.headers.get('etag');
|
||||
if (etag) assetsEtagRef.current = etag;
|
||||
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
|
||||
const pagination = payload?.data?.pagination;
|
||||
setAssets(list);
|
||||
setHasMoreAssets(!!pagination?.hasMore);
|
||||
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
|
||||
} catch {
|
||||
if (!silent) {
|
||||
toast.error('Failed to fetch assets');
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setIsLoadingAssets(false);
|
||||
}
|
||||
const etag = res.headers.get('etag');
|
||||
if (etag) assetsEtagRef.current = etag;
|
||||
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
|
||||
const pagination = payload?.data?.pagination;
|
||||
setAssets(list);
|
||||
setHasMoreAssets(!!pagination?.hasMore);
|
||||
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
|
||||
} catch {
|
||||
if (!silent) {
|
||||
toast.error('Failed to fetch assets');
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setIsLoadingAssets(false);
|
||||
}
|
||||
}, [videoId]);
|
||||
},
|
||||
[videoId]
|
||||
);
|
||||
|
||||
const loadMoreAssets = useCallback(async () => {
|
||||
if (isLoadingMoreAssets || !hasMoreAssets) return;
|
||||
setIsLoadingMoreAssets(true);
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=${nextAssetsOffset}`, { cache: 'no-store' });
|
||||
const res = await fetch(
|
||||
`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=${nextAssetsOffset}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
|
||||
if (!res.ok) {
|
||||
toast.error(payload?.error || 'Failed to load more assets');
|
||||
@@ -110,7 +119,10 @@ export function useVideoAssets({
|
||||
}
|
||||
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
|
||||
const pagination = payload?.data?.pagination;
|
||||
setAssets((prev) => [...prev, ...list.filter((asset) => !prev.some((existing) => existing.id === asset.id))]);
|
||||
setAssets((prev) => [
|
||||
...prev,
|
||||
...list.filter((asset) => !prev.some((existing) => existing.id === asset.id)),
|
||||
]);
|
||||
setHasMoreAssets(!!pagination?.hasMore);
|
||||
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
|
||||
} catch {
|
||||
@@ -145,118 +157,135 @@ export function useVideoAssets({
|
||||
};
|
||||
}, [fetchAssets, isLoadingMoreAssets]);
|
||||
|
||||
const createAsset = useCallback(async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
|
||||
if (!canUploadAssets) {
|
||||
toast.error('You do not have permission to upload assets');
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsCreatingAsset(true);
|
||||
isMutatingRef.current = true;
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/assets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
...(isAuthenticated ? {} : { guestName: guestName?.trim() || 'Guest' }),
|
||||
}),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
|
||||
if (!res.ok || !body?.data) {
|
||||
toast.error(body?.error || 'Failed to create asset');
|
||||
const createAsset = useCallback(
|
||||
async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
|
||||
if (!canUploadAssets) {
|
||||
toast.error('You do not have permission to upload assets');
|
||||
return null;
|
||||
}
|
||||
|
||||
setAssets((prev) => [body.data!, ...prev]);
|
||||
setNextAssetsOffset((prev) => prev + 1);
|
||||
return body.data;
|
||||
} catch {
|
||||
toast.error('Failed to create asset');
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreatingAsset(false);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
|
||||
|
||||
const deleteAsset = useCallback(async (assetId: string) => {
|
||||
setActiveDeleteAssetId(assetId);
|
||||
isMutatingRef.current = true;
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!res.ok) {
|
||||
toast.error(payload?.error || 'Failed to delete asset');
|
||||
return false;
|
||||
}
|
||||
|
||||
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
|
||||
setNextAssetsOffset((prev) => Math.max(0, prev - 1));
|
||||
return true;
|
||||
} catch {
|
||||
toast.error('Failed to delete asset');
|
||||
return false;
|
||||
} finally {
|
||||
setActiveDeleteAssetId(null);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const downloadAsset = useCallback(async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
|
||||
if (!canDownloadAssets) {
|
||||
toast.error('Asset downloads require an authenticated account');
|
||||
return;
|
||||
}
|
||||
if (asset.provider === 'YOUTUBE') {
|
||||
toast.error('YouTube assets cannot be downloaded');
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveDownloadAssetId(asset.id);
|
||||
try {
|
||||
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
|
||||
|
||||
if (asset.provider === 'BUNNY') {
|
||||
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, { cache: 'no-store' });
|
||||
const prepareBody = (await prepareRes.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!prepareRes.ok) {
|
||||
toast.error(prepareBody?.error || 'Download is not available');
|
||||
return;
|
||||
setIsCreatingAsset(true);
|
||||
isMutatingRef.current = true;
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/assets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
...(isAuthenticated ? {} : { guestName: guestName?.trim() || 'Guest' }),
|
||||
}),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
|
||||
if (!res.ok || !body?.data) {
|
||||
toast.error(body?.error || 'Failed to create asset');
|
||||
return null;
|
||||
}
|
||||
downloadUrl = `${downloadUrl}?source=${preference}`;
|
||||
|
||||
setAssets((prev) => [body.data!, ...prev]);
|
||||
setNextAssetsOffset((prev) => prev + 1);
|
||||
return body.data;
|
||||
} catch {
|
||||
toast.error('Failed to create asset');
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreatingAsset(false);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
},
|
||||
[canUploadAssets, videoId, isAuthenticated, guestName]
|
||||
);
|
||||
|
||||
const deleteAsset = useCallback(
|
||||
async (assetId: string) => {
|
||||
setActiveDeleteAssetId(assetId);
|
||||
isMutatingRef.current = true;
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!res.ok) {
|
||||
toast.error(payload?.error || 'Failed to delete asset');
|
||||
return false;
|
||||
}
|
||||
|
||||
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
|
||||
setNextAssetsOffset((prev) => Math.max(0, prev - 1));
|
||||
return true;
|
||||
} catch {
|
||||
toast.error('Failed to delete asset');
|
||||
return false;
|
||||
} finally {
|
||||
setActiveDeleteAssetId(null);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
},
|
||||
[videoId]
|
||||
);
|
||||
|
||||
const downloadAsset = useCallback(
|
||||
async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
|
||||
if (!canDownloadAssets) {
|
||||
toast.error('Asset downloads require an authenticated account');
|
||||
return;
|
||||
}
|
||||
if (asset.provider === 'YOUTUBE') {
|
||||
toast.error('YouTube assets cannot be downloaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch {
|
||||
toast.error('Failed to start download');
|
||||
} finally {
|
||||
setActiveDownloadAssetId(null);
|
||||
}
|
||||
}, [canDownloadAssets, videoId]);
|
||||
setActiveDownloadAssetId(asset.id);
|
||||
try {
|
||||
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
|
||||
|
||||
const getGuestUploadToken = useCallback(async (intent: 'image' | 'audio') => {
|
||||
if (isAuthenticated) return null;
|
||||
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ intent }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { data?: { token?: string }; error?: string }
|
||||
| null;
|
||||
const token = payload?.data?.token;
|
||||
if (!response.ok || !token) {
|
||||
throw new Error(payload?.error || 'Failed to prepare upload');
|
||||
}
|
||||
return token;
|
||||
}, [isAuthenticated, videoId]);
|
||||
if (asset.provider === 'BUNNY') {
|
||||
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const prepareBody = (await prepareRes.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!prepareRes.ok) {
|
||||
toast.error(prepareBody?.error || 'Download is not available');
|
||||
return;
|
||||
}
|
||||
downloadUrl = `${downloadUrl}?source=${preference}`;
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch {
|
||||
toast.error('Failed to start download');
|
||||
} finally {
|
||||
setActiveDownloadAssetId(null);
|
||||
}
|
||||
},
|
||||
[canDownloadAssets, videoId]
|
||||
);
|
||||
|
||||
const getGuestUploadToken = useCallback(
|
||||
async (intent: 'image' | 'audio') => {
|
||||
if (isAuthenticated) return null;
|
||||
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ intent }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
data?: { token?: string };
|
||||
error?: string;
|
||||
} | null;
|
||||
const token = payload?.data?.token;
|
||||
if (!response.ok || !token) {
|
||||
throw new Error(payload?.error || 'Failed to prepare upload');
|
||||
}
|
||||
return token;
|
||||
},
|
||||
[isAuthenticated, videoId]
|
||||
);
|
||||
|
||||
return {
|
||||
assets,
|
||||
|
||||
@@ -9,11 +9,7 @@ interface UseVideoPageDataParams {
|
||||
propProjectId?: string;
|
||||
}
|
||||
|
||||
export function useVideoPageData({
|
||||
mode,
|
||||
videoId,
|
||||
propProjectId,
|
||||
}: UseVideoPageDataParams) {
|
||||
export function useVideoPageData({ mode, videoId, propProjectId }: UseVideoPageDataParams) {
|
||||
const [video, setVideo] = useState<VideoData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -79,11 +75,11 @@ export function useVideoPageData({
|
||||
|
||||
return {
|
||||
...prev,
|
||||
versions: prev.versions.map((version) => (
|
||||
versions: prev.versions.map((version) =>
|
||||
version.id === versionId
|
||||
? { ...version, comments: commentsList, _count: { comments: totalComments } }
|
||||
: version
|
||||
)),
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
@@ -94,9 +90,10 @@ export function useVideoPageData({
|
||||
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'
|
||||
setError(
|
||||
mode === 'dashboard'
|
||||
? `Failed to load video: ${res.status} ${errorText}`
|
||||
: 'Video not found or access denied'
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -114,7 +111,8 @@ export function useVideoPageData({
|
||||
};
|
||||
|
||||
setVideo(normalizedData);
|
||||
const active = normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0];
|
||||
const active =
|
||||
normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0];
|
||||
if (active) setActiveVersionId(active.id);
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
|
||||
@@ -27,12 +27,9 @@ interface UseVideoPlayerParams {
|
||||
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>;
|
||||
scheduleWatchProgressSaveRef: RefObject<
|
||||
(input: { progress: number; duration?: number; immediate?: boolean; force?: boolean }) => void
|
||||
>;
|
||||
setViewingAnnotation: (strokes: AnnotationStroke[] | null) => void;
|
||||
}
|
||||
|
||||
@@ -166,11 +163,19 @@ export function useVideoPlayer({
|
||||
setIsBunnyPortraitSource(false);
|
||||
|
||||
if (playerRef.current) {
|
||||
try { playerRef.current.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
playerRef.current.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
playerRef.current = null;
|
||||
}
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
hlsRef.current.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (bunnyRetryTimerRef.current) {
|
||||
@@ -222,7 +227,8 @@ export function useVideoPlayer({
|
||||
let retryAttempt = 0;
|
||||
let usingHlsJs = false;
|
||||
let hlsInstance: Hls | null = null;
|
||||
let sourceMode: 'hls' | 'original' = bunnySourcePreference === 'original' ? 'original' : 'hls';
|
||||
let sourceMode: 'hls' | 'original' =
|
||||
bunnySourcePreference === 'original' ? 'original' : 'hls';
|
||||
const clearRetryTimer = () => {
|
||||
if (bunnyRetryTimerRef.current) {
|
||||
clearTimeout(bunnyRetryTimerRef.current);
|
||||
@@ -268,7 +274,11 @@ export function useVideoPlayer({
|
||||
usingHlsJs = false;
|
||||
clearRetryTimer();
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
hlsRef.current.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
hlsRef.current = null;
|
||||
}
|
||||
hlsInstance = null;
|
||||
@@ -288,7 +298,10 @@ export function useVideoPlayer({
|
||||
|
||||
const saveProgress = () => {
|
||||
const current = videoEl.currentTime || 0;
|
||||
const duration = Number.isFinite(videoEl.duration) && videoEl.duration > 0 ? videoEl.duration : cachedDuration;
|
||||
const duration =
|
||||
Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
||||
? videoEl.duration
|
||||
: cachedDuration;
|
||||
scheduleWatchProgressSaveRef.current({
|
||||
progress: current,
|
||||
duration,
|
||||
@@ -310,17 +323,23 @@ export function useVideoPlayer({
|
||||
setIsReady(true);
|
||||
const resumeState = bunnySourceSwitchResumeRef.current;
|
||||
if (resumeState) {
|
||||
const knownDuration = Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
||||
? videoEl.duration
|
||||
: cachedDuration;
|
||||
const targetTime = knownDuration > 0
|
||||
? Math.min(Math.max(0, resumeState.time), Math.max(0, knownDuration - 0.01))
|
||||
: Math.max(0, resumeState.time);
|
||||
const knownDuration =
|
||||
Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
||||
? videoEl.duration
|
||||
: cachedDuration;
|
||||
const targetTime =
|
||||
knownDuration > 0
|
||||
? Math.min(Math.max(0, resumeState.time), Math.max(0, knownDuration - 0.01))
|
||||
: Math.max(0, resumeState.time);
|
||||
videoEl.currentTime = targetTime;
|
||||
setCurrentTime(targetTime);
|
||||
bunnySourceSwitchResumeRef.current = null;
|
||||
if (resumeState.wasPlaying) {
|
||||
videoEl.play().catch((err) => console.error('Error resuming Bunny video after source switch:', err));
|
||||
videoEl
|
||||
.play()
|
||||
.catch((err) =>
|
||||
console.error('Error resuming Bunny video after source switch:', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
syncDuration();
|
||||
@@ -348,7 +367,11 @@ export function useVideoPlayer({
|
||||
if (!isDraggingRef.current) {
|
||||
setCurrentTime(videoEl.currentTime || 0);
|
||||
}
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0 && videoEl.duration !== cachedDuration) {
|
||||
if (
|
||||
Number.isFinite(videoEl.duration) &&
|
||||
videoEl.duration > 0 &&
|
||||
videoEl.duration !== cachedDuration
|
||||
) {
|
||||
cachedDuration = videoEl.duration;
|
||||
setVideoDuration(videoEl.duration);
|
||||
}
|
||||
@@ -380,10 +403,12 @@ export function useVideoPlayer({
|
||||
videoEl.addEventListener('error', onVideoError);
|
||||
|
||||
const configureHlsLevels = (levels: Level[]) => {
|
||||
setQualityOptions(levels.map((level, index) => ({
|
||||
level: index,
|
||||
label: formatBunnyQualityLabel(level, index),
|
||||
})));
|
||||
setQualityOptions(
|
||||
levels.map((level, index) => ({
|
||||
level: index,
|
||||
label: formatBunnyQualityLabel(level, index),
|
||||
}))
|
||||
);
|
||||
const pendingQuality = pendingHlsQualityRef.current;
|
||||
pendingHlsQualityRef.current = null;
|
||||
|
||||
@@ -438,24 +463,29 @@ export function useVideoPlayer({
|
||||
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) {
|
||||
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
|
||||
) {
|
||||
if (activateOriginalFallback()) {
|
||||
return;
|
||||
}
|
||||
@@ -495,11 +525,10 @@ export function useVideoPlayer({
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () => (
|
||||
getPlayerState: () =>
|
||||
videoEl.paused
|
||||
? (window.YT?.PlayerState?.PAUSED ?? 2)
|
||||
: (window.YT?.PlayerState?.PLAYING ?? 1)
|
||||
),
|
||||
: (window.YT?.PlayerState?.PLAYING ?? 1),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
@@ -513,7 +542,11 @@ export function useVideoPlayer({
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.removeEventListener('error', onVideoError);
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
hlsRef.current.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
hlsRef.current = null;
|
||||
}
|
||||
videoEl.removeAttribute('src');
|
||||
@@ -541,11 +574,19 @@ export function useVideoPlayer({
|
||||
window.onYouTubeIframeAPIReady = undefined;
|
||||
}
|
||||
if (playerRef.current) {
|
||||
try { playerRef.current.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
playerRef.current.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
playerRef.current = null;
|
||||
}
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
hlsRef.current.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (bunnyRetryTimerRef.current) {
|
||||
@@ -553,25 +594,44 @@ export function useVideoPlayer({
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, bunnySourcePreference, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]);
|
||||
}, [
|
||||
activeProviderId,
|
||||
activeVersionId,
|
||||
embedUrl,
|
||||
isApiLoaded,
|
||||
canInitializePlayer,
|
||||
formatBunnyQualityLabel,
|
||||
bunnySourcePreference,
|
||||
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');
|
||||
});
|
||||
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');
|
||||
});
|
||||
document
|
||||
.exitFullscreen()
|
||||
.then(() => {
|
||||
setIsFullscreenMode(false);
|
||||
setShowComments(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Exit fullscreen failed:', err);
|
||||
toast.error('Unable to exit fullscreen mode');
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -730,7 +790,16 @@ export function useVideoPlayer({
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
|
||||
}, [
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
isMuted,
|
||||
playbackSpeed,
|
||||
speedOptions,
|
||||
toggleFullscreen,
|
||||
playerRef,
|
||||
]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (!playerRef.current) return;
|
||||
@@ -741,41 +810,41 @@ export function useVideoPlayer({
|
||||
}
|
||||
}, [isPlaying, playerRef]);
|
||||
|
||||
const handleSeekToTimestamp = useCallback((
|
||||
timestamp: number,
|
||||
annotation?: string | null,
|
||||
options?: { pauseAfterSeek?: boolean }
|
||||
) => {
|
||||
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;
|
||||
const handleSeekToTimestamp = useCallback(
|
||||
(timestamp: number, annotation?: string | null, options?: { pauseAfterSeek?: boolean }) => {
|
||||
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 (options?.pauseAfterSeek) {
|
||||
playerRef.current.pauseVideo();
|
||||
} else if (wasPlayingBeforeSeek) {
|
||||
playerRef.current.playVideo();
|
||||
} else {
|
||||
playerRef.current.pauseVideo();
|
||||
playerRef.current.seekTo(timestamp, true);
|
||||
if (options?.pauseAfterSeek) {
|
||||
playerRef.current.pauseVideo();
|
||||
} else if (wasPlayingBeforeSeek) {
|
||||
playerRef.current.playVideo();
|
||||
} else {
|
||||
playerRef.current.pauseVideo();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (annotation) {
|
||||
try {
|
||||
const parsed = JSON.parse(annotation);
|
||||
const safe = validateAnnotationStrokes(parsed);
|
||||
setViewingAnnotation(safe as AnnotationStroke[] | null);
|
||||
} catch {
|
||||
if (annotation) {
|
||||
try {
|
||||
const parsed = JSON.parse(annotation);
|
||||
const safe = validateAnnotationStrokes(parsed);
|
||||
setViewingAnnotation(safe as AnnotationStroke[] | null);
|
||||
} catch {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
} else {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
} else {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
}, [isPlaying, playerRef, setViewingAnnotation]);
|
||||
},
|
||||
[isPlaying, playerRef, setViewingAnnotation]
|
||||
);
|
||||
|
||||
const handleMuteToggle = useCallback(() => {
|
||||
if (!playerRef.current) return;
|
||||
@@ -803,49 +872,51 @@ export function useVideoPlayer({
|
||||
[playerRef]
|
||||
);
|
||||
|
||||
const handleQualityChange = useCallback((level: number) => {
|
||||
const shouldCaptureSourceSwitch = (
|
||||
activeProviderId === 'bunny'
|
||||
&& ((level === -2 && bunnySourcePreference !== 'original')
|
||||
|| (level !== -2 && bunnySourcePreference === 'original'))
|
||||
);
|
||||
const handleQualityChange = useCallback(
|
||||
(level: number) => {
|
||||
const shouldCaptureSourceSwitch =
|
||||
activeProviderId === 'bunny' &&
|
||||
((level === -2 && bunnySourcePreference !== 'original') ||
|
||||
(level !== -2 && bunnySourcePreference === 'original'));
|
||||
|
||||
if (shouldCaptureSourceSwitch) {
|
||||
const fallbackCurrentTime = videoRef.current?.currentTime ?? 0;
|
||||
const current = playerRef.current?.getCurrentTime?.() ?? fallbackCurrentTime;
|
||||
bunnySourceSwitchResumeRef.current = {
|
||||
time: Number.isFinite(current) ? Math.max(0, current) : 0,
|
||||
wasPlaying: isPlaying,
|
||||
};
|
||||
}
|
||||
if (shouldCaptureSourceSwitch) {
|
||||
const fallbackCurrentTime = videoRef.current?.currentTime ?? 0;
|
||||
const current = playerRef.current?.getCurrentTime?.() ?? fallbackCurrentTime;
|
||||
bunnySourceSwitchResumeRef.current = {
|
||||
time: Number.isFinite(current) ? Math.max(0, current) : 0,
|
||||
wasPlaying: isPlaying,
|
||||
};
|
||||
}
|
||||
|
||||
if (level === -2) {
|
||||
pendingHlsQualityRef.current = null;
|
||||
setBunnySourcePreference('original');
|
||||
setSelectedQualityLevel(-2);
|
||||
return;
|
||||
}
|
||||
if (level === -2) {
|
||||
pendingHlsQualityRef.current = null;
|
||||
setBunnySourcePreference('original');
|
||||
setSelectedQualityLevel(-2);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingHlsQualityRef.current = level;
|
||||
setBunnySourcePreference('auto');
|
||||
pendingHlsQualityRef.current = level;
|
||||
setBunnySourcePreference('auto');
|
||||
|
||||
const hls = hlsRef.current;
|
||||
if (!hls) {
|
||||
setSelectedQualityLevel(level === -1 ? -1 : level);
|
||||
return;
|
||||
}
|
||||
const hls = hlsRef.current;
|
||||
if (!hls) {
|
||||
setSelectedQualityLevel(level === -1 ? -1 : level);
|
||||
return;
|
||||
}
|
||||
|
||||
if (level === -1) {
|
||||
hls.currentLevel = -1;
|
||||
hls.nextLevel = -1;
|
||||
setSelectedQualityLevel(-1);
|
||||
return;
|
||||
}
|
||||
if (level === -1) {
|
||||
hls.currentLevel = -1;
|
||||
hls.nextLevel = -1;
|
||||
setSelectedQualityLevel(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
hls.currentLevel = level;
|
||||
hls.nextLevel = level;
|
||||
setSelectedQualityLevel(level);
|
||||
}, [activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]);
|
||||
hls.currentLevel = level;
|
||||
hls.nextLevel = level;
|
||||
setSelectedQualityLevel(level);
|
||||
},
|
||||
[activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]
|
||||
);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -28,7 +28,11 @@ export function useWatchProgress({
|
||||
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 pendingProgressPayloadRef = useRef<{
|
||||
progress: number;
|
||||
duration: number;
|
||||
force: boolean;
|
||||
} | null>(null);
|
||||
const lastSavedProgressRef = useRef<number>(0);
|
||||
const lastPathnameRef = useRef<string>(pathname);
|
||||
|
||||
@@ -69,51 +73,49 @@ export function useWatchProgress({
|
||||
}
|
||||
}, [isAuthenticated, activeVersionId, videoId]);
|
||||
|
||||
const scheduleWatchProgressSave = useCallback((input: {
|
||||
progress: number;
|
||||
duration?: number;
|
||||
immediate?: boolean;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
if (!isAuthenticated || !activeVersionId) return;
|
||||
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 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;
|
||||
const duration = Math.max(0, input.duration ?? videoDurationRef.current ?? 0);
|
||||
const force = input.force ?? false;
|
||||
|
||||
if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
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;
|
||||
}
|
||||
: { progress, duration, force };
|
||||
void flushScheduledWatchProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
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]);
|
||||
progressDebounceTimerRef.current = setTimeout(() => {
|
||||
progressDebounceTimerRef.current = null;
|
||||
void flushScheduledWatchProgress();
|
||||
}, 800);
|
||||
},
|
||||
[isAuthenticated, activeVersionId, flushScheduledWatchProgress]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -138,28 +140,31 @@ export function useWatchProgress({
|
||||
}
|
||||
}, [videoId, activeVersionId]);
|
||||
|
||||
const loadWatchProgress = useCallback(async (showPrompt = true) => {
|
||||
if (!isAuthenticated || !activeVersionId) return;
|
||||
const loadWatchProgress = useCallback(
|
||||
async (showPrompt = true) => {
|
||||
if (!isAuthenticated || !activeVersionId) return;
|
||||
|
||||
setSavedProgress(null);
|
||||
setShowResumePrompt(false);
|
||||
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;
|
||||
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);
|
||||
if (showPrompt && percentage > 5 && percentage < 95) {
|
||||
setSavedProgress(progress);
|
||||
setShowResumePrompt(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading watch progress:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading watch progress:', err);
|
||||
}
|
||||
}, [isAuthenticated, activeVersionId, videoId]);
|
||||
},
|
||||
[isAuthenticated, activeVersionId, videoId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadWatchProgress();
|
||||
@@ -194,7 +199,14 @@ export function useWatchProgress({
|
||||
progressSaveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, isReady, videoDuration, activeVersionId, scheduleWatchProgressSave, playerRef]);
|
||||
}, [
|
||||
isAuthenticated,
|
||||
isReady,
|
||||
videoDuration,
|
||||
activeVersionId,
|
||||
scheduleWatchProgressSave,
|
||||
playerRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
@@ -211,11 +223,16 @@ export function useWatchProgress({
|
||||
clearTimeout(progressDebounceTimerRef.current);
|
||||
progressDebounceTimerRef.current = null;
|
||||
}
|
||||
const data = new Blob([JSON.stringify({
|
||||
progress: finalProgress,
|
||||
duration: finalDuration,
|
||||
versionId: activeVersionId,
|
||||
})], { type: 'application/json' });
|
||||
const data = new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
progress: finalProgress,
|
||||
duration: finalDuration,
|
||||
versionId: activeVersionId,
|
||||
}),
|
||||
],
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
|
||||
}
|
||||
};
|
||||
@@ -240,7 +257,15 @@ export function useWatchProgress({
|
||||
window.removeEventListener('beforeunload', saveProgressOnLeave);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [isAuthenticated, currentTime, videoDuration, activeVersionId, videoId, scheduleWatchProgressSave, playerRef]);
|
||||
}, [
|
||||
isAuthenticated,
|
||||
currentTime,
|
||||
videoDuration,
|
||||
activeVersionId,
|
||||
videoId,
|
||||
scheduleWatchProgressSave,
|
||||
playerRef,
|
||||
]);
|
||||
|
||||
const handleResumeFromSaved = useCallback(() => {
|
||||
if (savedProgress !== null && playerRef.current) {
|
||||
|
||||
Reference in New Issue
Block a user