diff --git a/app/analyze/[videoId]/page.tsx b/app/analyze/[videoId]/page.tsx index 66b1a39..154da97 100644 --- a/app/analyze/[videoId]/page.tsx +++ b/app/analyze/[videoId]/page.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { RightColumnTabs, type RightColumnTabsHandle } from "@/components/right-column-tabs"; -import { YouTubePlayer } from "@/components/youtube-player"; +import { YouTubePlayer, type YouTubePlayerHandle } from "@/components/youtube-player"; import { HighlightsPanel } from "@/components/highlights-panel"; import { ThemeSelector } from "@/components/theme-selector"; import { LoadingContext } from "@/components/loading-context"; @@ -24,6 +24,13 @@ import { useTranscriptExport } from "@/lib/hooks/use-transcript-export"; // Page state for better UX type PageState = 'IDLE' | 'ANALYZING_NEW' | 'LOADING_CACHED'; type AuthModalTrigger = 'generation-limit' | 'save-video' | 'manual' | 'save-note'; +type CachedHighlightPayload = { + videoId: string; + videoDbId?: string | null; + topics: Topic[]; + themes?: string[]; + topicCandidates?: TopicCandidate[]; +}; import { buildVideoSlug, extractVideoId } from "@/lib/utils"; import { getLanguageName } from "@/lib/language-utils"; import { NO_CREDITS_USED_MESSAGE } from "@/lib/no-credits-message"; @@ -67,24 +74,6 @@ type LimitCheckResponse = { }; -function buildLimitExceededMessage(limitData?: LimitCheckResponse | null): string { - if (!limitData) { - return AUTH_LIMIT_MESSAGE; - } - - if (limitData.reason === 'SUBSCRIPTION_INACTIVE') { - return 'Your subscription is not active. Visit the billing portal to reactivate and continue generating videos.'; - } - - if (limitData.tier === 'pro') { - return limitData.requiresTopup - ? 'You have used all Pro videos this period. Purchase a Top-Up (+20 videos for $2.99) or wait for your next billing cycle.' - : 'You have used your Pro allowance. Wait for your next billing cycle to reset.'; - } - - return AUTH_LIMIT_MESSAGE; -} - function normalizeErrorMessage(message: string | undefined, fallback: string = DEFAULT_CLIENT_ERROR): string { const trimmed = typeof message === "string" ? message.trim() : ""; const baseMessage = trimmed.length > 0 ? trimmed : fallback; @@ -196,7 +185,7 @@ export default function AnalyzePage() { : 'IDLE' ); const hasAttemptedLinking = useRef(false); - const [loadingStage, setLoadingStage] = useState<'fetching' | 'understanding' | 'generating' | 'processing' | null>(null); + const [loadingStage, setLoadingStage] = useState<'fetching' | 'understanding' | null>(null); const { mode, isLoading: isModeLoading } = useModePreference(); const [error, setError] = useState(""); const [isRateLimitError, setIsRateLimitError] = useState(false); @@ -224,6 +213,9 @@ export default function AnalyzePage() { return keys; }, [baseTopics]); const [isLoadingThemeTopics, setIsLoadingThemeTopics] = useState(false); + const [isGeneratingHighlights, setIsGeneratingHighlights] = useState(false); + const [highlightGenerationError, setHighlightGenerationError] = useState(null); + const [highlightGenerationStartTime, setHighlightGenerationStartTime] = useState(null); const [themeError, setThemeError] = useState(null); const [switchingToLanguage, setSwitchingToLanguage] = useState(null); const [selectedTopic, setSelectedTopic] = useState(null); @@ -235,10 +227,10 @@ export default function AnalyzePage() { const [playbackCommand, setPlaybackCommand] = useState(null); const [transcriptHeight, setTranscriptHeight] = useState("auto"); const [citationHighlight, setCitationHighlight] = useState(null); - const [generationStartTime, setGenerationStartTime] = useState(null); - const [processingStartTime, setProcessingStartTime] = useState(null); const rightColumnTabsRef = useRef(null); + const youtubePlayerRef = useRef(null); const abortManager = useRef(new AbortManager()); + const cachedHighlightPayloadRef = useRef(null); const selectedThemeRef = useRef(null); const seoPathRef = useRef(null); const nextThemeRequestIdRef = useRef(0); @@ -321,8 +313,7 @@ export default function AnalyzePage() { }, [isShareReady, routeVideoId, videoId, videoInfo?.title, slugParam]); // Use custom hook for timer logic - const elapsedTime = useElapsedTimer(generationStartTime); - const processingElapsedTime = useElapsedTimer(processingStartTime); + const highlightGenerationElapsedTime = useElapsedTimer(highlightGenerationStartTime); // Auth and generation limit state const { user } = useAuth(); @@ -410,15 +401,14 @@ export default function AnalyzePage() { translationCache: translationCache, }); - const [rateLimitInfo, setRateLimitInfo] = useState<{ - remaining: number | null; - resetAt: Date | null; - }>({ remaining: -1, resetAt: null }); - const [authLimitReached, setAuthLimitReached] = useState(false); const hasRedirectedForLimit = useRef(false); // Centralized playback request functions const requestSeek = useCallback((time: number) => { + if (youtubePlayerRef.current?.seekTo(time)) { + return; + } + setPlaybackCommand({ type: 'SEEK', time }); }, []); @@ -555,27 +545,9 @@ export default function AnalyzePage() { const response = await fetch('/api/check-limit'); const data: LimitCheckResponse = await response.json(); - setAuthLimitReached(Boolean(data?.isAuthenticated && data?.canGenerate === false && data?.reason === 'LIMIT_REACHED')); - - const usage = data?.usage; - const remainingValue = - typeof usage?.totalRemaining === 'number' - ? usage.totalRemaining - : usage?.totalRemaining === null - ? null - : -1; - - const resetTimestamp = data?.resetAt ?? null; - - setRateLimitInfo({ - remaining: remainingValue, - resetAt: resetTimestamp ? new Date(resetTimestamp) : null, - }); - return data; } catch (error) { console.error('Error checking rate limit:', error); - setAuthLimitReached(false); return null; } }, []); @@ -624,58 +596,11 @@ export default function AnalyzePage() { ); }, [authErrorParam, router, routeVideoId, searchParams]); - // Automatically kick off analysis when arriving via dedicated route - // Check if user can generate based on server-side rate limits - const checkGenerationLimit = useCallback(( - pendingVideoId?: string, - remainingOverride?: number | null, - latestLimitData?: LimitCheckResponse | null - ): boolean => { - if (user) { - const limitReached = - latestLimitData?.isAuthenticated - ? latestLimitData.canGenerate === false - : authLimitReached; - - if (limitReached) { - const limitMessage = buildLimitExceededMessage(latestLimitData); - setIsRateLimitError(true); - setError(limitMessage); - toast.error(limitMessage); - return false; - } - return true; - } - - let effectiveRemaining = - typeof remainingOverride === 'number' || remainingOverride === null - ? remainingOverride - : rateLimitInfo.remaining; - - if (!latestLimitData?.isAuthenticated) { - const totalRemaining = latestLimitData?.usage?.totalRemaining; - if (typeof totalRemaining === 'number' || totalRemaining === null) { - effectiveRemaining = totalRemaining; - } - } - - if ( - typeof effectiveRemaining === 'number' && - effectiveRemaining !== -1 && - effectiveRemaining <= 0 - ) { - redirectToAuthForLimit(undefined, pendingVideoId); - return false; - } - return true; - }, [user, authLimitReached, rateLimitInfo.remaining, redirectToAuthForLimit]); - const processVideo = useCallback(async ( url: string, selectedMode: TopicGenerationMode, preferredLanguage?: string ) => { - const currentRemaining = rateLimitInfo.remaining; try { const extractedVideoId = extractVideoId(url); if (!extractedVideoId) { @@ -693,6 +618,7 @@ export default function AnalyzePage() { setIsRateLimitError(false); setTopics([]); setBaseTopics([]); + cachedHighlightPayloadRef.current = null; setTranscript([]); setThemes([]); setSelectedTheme(null); @@ -701,6 +627,8 @@ export default function AnalyzePage() { setUsedTopicKeys(new Set()); setThemeError(null); setIsLoadingThemeTopics(false); + setIsGeneratingHighlights(false); + setHighlightGenerationError(null); setSelectedTopic(null); setCurrentTime(0); setVideoDuration(0); @@ -757,13 +685,18 @@ export default function AnalyzePage() { // Otherwise, set it now setPageState('LOADING_CACHED'); - const hydratedTopics = hydrateTopicsWithTranscript( - Array.isArray(cacheData.topics) ? cacheData.topics : [], - sanitizedTranscript, - ); - // Load all cached data setTranscript(sanitizedTranscript); + cachedHighlightPayloadRef.current = + Array.isArray(cacheData.topics) && cacheData.topics.length > 0 + ? { + videoId: extractedVideoId, + videoDbId: cacheData.videoDbId ?? null, + topics: cacheData.topics, + themes: Array.isArray(cacheData.themes) ? cacheData.themes : undefined, + topicCandidates: Array.isArray(cacheData.topicCandidates) ? cacheData.topicCandidates : undefined, + } + : null; const cachedVideoInfo = cacheData.videoInfo ?? null; if (cachedVideoInfo) { @@ -782,18 +715,6 @@ export default function AnalyzePage() { setVideoInfo(null); } - setTopics(hydratedTopics); - setBaseTopics(hydratedTopics); - const initialKeys = new Set(); - hydratedTopics.forEach(topic => { - if (topic.quote?.timestamp && topic.quote.text) { - const key = `${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`; - initialKeys.add(key); - } - }); - setUsedTopicKeys(initialKeys); - setSelectedTopic(hydratedTopics.length > 0 ? hydratedTopics[0] : null); - // Set cached takeaways and questions if (cacheData.summary) { setTakeawaysContent(cacheData.summary); @@ -810,49 +731,9 @@ export default function AnalyzePage() { // Set page state back to idle setPageState('IDLE'); setLoadingStage(null); - setProcessingStartTime(null); setSwitchingToLanguage(null); setIsShareReady(true); - backgroundOperation( - 'load-cached-themes', - async () => { - const response = await fetch("/api/video-analysis", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - videoId: extractedVideoId, - videoInfo: cacheData.videoInfo, - transcript: sanitizedTranscript, - includeCandidatePool: true, - mode: selectedMode, - forceRegenerate: false - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: "Unknown error" })); - const message = buildApiErrorMessage(errorData, "Failed to generate themes"); - throw new Error(message); - } - - const data = await response.json(); - if (Array.isArray(data.themes)) { - setThemes(data.themes); - } - if (Array.isArray(data.topicCandidates)) { - setThemeCandidateMap(prev => ({ - ...prev, - __default: data.topicCandidates - })); - } - return data.themes; - }, - (error) => { - console.error("Failed to generate themes for cached video:", error); - } - ); - // Fetch available transcript languages for cached videos // This enables the language selector dropdown to show all available native languages // NOTE: Only update availableLanguages, preserve the cached language value @@ -904,30 +785,31 @@ export default function AnalyzePage() { }), }); - if (summaryRes.ok) { - const { summaryContent: generatedTakeaways } = await summaryRes.json(); - setTakeawaysContent(generatedTakeaways); - - // Update the video analysis with the takeaways (requires auth + ownership) - await backgroundOperation( - 'update-cached-takeaways', - async () => { - const res = await csrfFetch.post("/api/update-video-analysis", { - videoId: extractedVideoId, - summary: generatedTakeaways - }); - // 401/403 is expected for anonymous users or non-owners - if (!res.ok && res.status !== 401 && res.status !== 403) { - throw new Error('Failed to update takeaways'); - } - } - ); - return generatedTakeaways; - } else { + if (!summaryRes.ok) { const errorData = await summaryRes.json().catch(() => ({ error: "Unknown error" })); const message = buildApiErrorMessage(errorData, "Failed to generate takeaways"); - throw new Error(message); + setTakeawaysError(message); + return null; } + + const { summaryContent: generatedTakeaways } = await summaryRes.json(); + setTakeawaysContent(generatedTakeaways); + + // Update the video analysis with the takeaways (requires auth + ownership) + await backgroundOperation( + 'update-cached-takeaways', + async () => { + const res = await csrfFetch.post("/api/update-video-analysis", { + videoId: extractedVideoId, + summary: generatedTakeaways + }); + // 401/403 is expected for anonymous users or non-owners + if (!res.ok && res.status !== 401 && res.status !== 403) { + throw new Error('Failed to update takeaways'); + } + } + ); + return generatedTakeaways; }, (error) => { setTakeawaysError(error.message || "Failed to generate takeaways. Please try again."); @@ -945,20 +827,6 @@ export default function AnalyzePage() { } } - let effectiveRemaining = currentRemaining; - const latestLimitData = await checkRateLimit(); - - if (!user && latestLimitData) { - const totalRemaining = latestLimitData.usage?.totalRemaining; - if (typeof totalRemaining === 'number' || totalRemaining === null) { - effectiveRemaining = totalRemaining; - } - } - - if (!checkGenerationLimit(extractedVideoId, effectiveRemaining, latestLimitData)) { - return; - } - setPageState('ANALYZING_NEW'); setLoadingStage('fetching'); @@ -1121,297 +989,69 @@ export default function AnalyzePage() { console.error('Error generating quick preview:', error); }); - // Initiate parallel API requests for topics and takeaways - setLoadingStage('generating'); - setGenerationStartTime(Date.now()); - - // Create abort controllers for both requests - const topicsController = abortManager.current.createController('topics'); + // Generate takeaways in the background. Highlight reels are intentionally + // deferred until the user clicks the generate button. const takeawaysController = abortManager.current.createController('takeaways', 60000); - // Start topics generation using cached video-analysis endpoint - const topicsPromise = fetch("/api/video-analysis", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - videoId: extractedVideoId, - videoInfo: fetchedVideoInfo, - transcript: normalizedTranscriptData, - mode: selectedMode, - forceRegenerate - }), - signal: topicsController.signal, - }).catch(err => { - if (err.name === 'AbortError') { - throw new Error("Topic generation was canceled or interrupted. Please try again."); - } - throw new Error("Network error: Unable to generate topics. Please check your connection."); - }); - - // Start takeaways generation in parallel (will be ignored if cached) - const takeawaysPromise = fetch("/api/generate-summary", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - transcript: normalizedTranscriptData, - videoInfo: fetchedVideoInfo, - videoId: extractedVideoId, - targetLanguage: fetchedVideoInfo?.language - }), - signal: takeawaysController.signal, - }); - // Show takeaways tab and loading state immediately (optimistic UI) setShowChatTab(true); setIsGeneratingTakeaways(true); - - const toSettled = (promise: Promise) => - promise.then( - (value) => ({ status: 'fulfilled', value } as const), - (reason) => ({ status: 'rejected', reason } as const) - ); - - const topicsSettledPromise = toSettled(topicsPromise); - const takeawaysSettledPromise = toSettled(takeawaysPromise); - - const topicsResult = await topicsSettledPromise; - if (topicsResult.status === 'rejected') { - takeawaysController.abort(); - await takeawaysSettledPromise; - throw topicsResult.reason; - } - - const topicsRes = topicsResult.value; - if (!topicsRes.ok) { - const errorData = await topicsRes.json().catch(() => ({ error: "Unknown error" })); - const requiresAuth = Boolean((errorData as any)?.requiresAuth); - const authMessage = - typeof (errorData as any)?.message === "string" - ? (errorData as any).message - : undefined; - - if (requiresAuth || topicsRes.status === 401 || topicsRes.status === 403) { - takeawaysController.abort(); - await takeawaysSettledPromise; - redirectToAuthForLimit( - authMessage, - extractedVideoId - ); - return; - } - - if (topicsRes.status === 429) { - setIsRateLimitError(true); - checkRateLimit(); - takeawaysController.abort(); - await takeawaysSettledPromise; - - const limitMessageRaw = - typeof (errorData as any)?.message === "string" - ? (errorData as any).message.trim() - : ""; - - const limitErrorRaw = - typeof (errorData as any)?.error === "string" - ? (errorData as any).error.trim() - : ""; - - const limitMessage = - limitMessageRaw.length > 0 - ? limitMessageRaw - : limitErrorRaw.length > 0 - ? limitErrorRaw - : AUTH_LIMIT_MESSAGE; - - throw new Error(limitMessage); - } - - takeawaysController.abort(); - await takeawaysSettledPromise; - const message = buildApiErrorMessage(errorData, "Failed to generate topics"); - throw new Error(message); - } - - const topicsData = await topicsRes.json(); - const rawTopics = Array.isArray(topicsData.topics) ? topicsData.topics : []; - const generatedTopics: Topic[] = hydrateTopicsWithTranscript(rawTopics, normalizedTranscriptData); - const generatedThemes: string[] = Array.isArray(topicsData.themes) ? topicsData.themes : []; - const rawCandidates: TopicCandidate[] = Array.isArray(topicsData.topicCandidates) ? topicsData.topicCandidates : []; - const generatedCandidates: TopicCandidate[] = rawCandidates.map(candidate => ({ - ...candidate, - key: `${candidate.quote.timestamp}|${normalizeWhitespace(candidate.quote.text)}` - })); - - // Capture database UUID for notes saving - if (topicsData.videoDbId) { - setVideoDbId(topicsData.videoDbId); - } - - const takeawaysResult = await takeawaysSettledPromise; - - // Move to processing stage - setLoadingStage('processing'); - setGenerationStartTime(null); - setProcessingStartTime(Date.now()); - - // Process takeaways result from parallel execution - let generatedTakeaways = null; - let takeawaysGenerationError = null; - if (takeawaysResult.status === 'fulfilled') { - const summaryRes = takeawaysResult.value; - - if (summaryRes.ok) { - const summaryData = await summaryRes.json(); - generatedTakeaways = summaryData.summaryContent; - } else { - const errorData = await summaryRes.json().catch(() => ({ error: "Unknown error" })); - takeawaysGenerationError = buildApiErrorMessage(errorData, "Failed to generate takeaways. Please try again."); - } - } else { - const error = takeawaysResult.reason; - if (error && error.name === 'AbortError') { - takeawaysGenerationError = "Takeaways generation timed out. The video might be too long."; - } else { - takeawaysGenerationError = error?.message || "Failed to generate takeaways. Please try again."; - } - } - - // Synchronous batch state update - all at once - setTopics(generatedTopics); - setBaseTopics(generatedTopics); - const initialKeys = new Set(); - generatedTopics.forEach(topic => { - if (topic.quote?.timestamp && topic.quote.text) { - initialKeys.add(`${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`); - } - }); - setUsedTopicKeys(initialKeys); - setThemeCandidateMap(prev => ({ - ...prev, - __default: generatedCandidates - })); - setSelectedTopic(generatedTopics.length > 0 ? generatedTopics[0] : null); - setThemes(generatedThemes); - if (generatedTakeaways) { - setTakeawaysContent(generatedTakeaways); - setShowChatTab(true); - setIsGeneratingTakeaways(false); - } else if (takeawaysGenerationError) { - setTakeawaysError(takeawaysGenerationError); - setShowChatTab(true); - setIsGeneratingTakeaways(false); - } - - // Rate limit is handled server-side now - checkRateLimit(); - - // Confirm the analysis has been persisted before switching to the shareable /v/ URL - backgroundOperation( - 'confirm-share-ready', - async () => { - if (!url) return false; - - const cacheCheck = await fetch("/api/check-video-cache", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url }) - }); - - if (!cacheCheck.ok) { - return false; - } - - const cacheData = await cacheCheck.json(); - if (cacheData?.cached) { - setIsShareReady(true); - return true; - } - - return false; - }, - (error) => { - console.error("Failed to confirm cached analysis for sharing:", error); - } - ); - - // NOTE: Video analysis is now saved server-side in /api/video-analysis - // to prevent client-side cache poisoning attacks - - // Generate suggested questions backgroundOperation( - 'generate-questions', + 'generate-takeaways', async () => { - const res = await fetch("/api/suggested-questions", { + const summaryRes = await fetch("/api/generate-summary", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transcript: normalizedTranscriptData, - topics: generatedTopics, - videoTitle: fetchedVideoInfo?.title, - language: fetchedVideoInfo?.language + videoInfo: fetchedVideoInfo, + videoId: extractedVideoId, + targetLanguage: fetchedVideoInfo?.language }), + signal: takeawaysController.signal, }); - const applyCachedQuestions = (questions: string[]) => { - if (questions.length === 0) { - return questions; - } - setCachedSuggestedQuestions(prev => { - if (prev && prev.length > 0) { - return prev; - } - return questions; - }); - return questions; - }; - - if (!res.ok) { - console.error("Failed to generate suggested questions:", res.status, res.statusText); - return applyCachedQuestions(buildSuggestedQuestionFallbacks(3)); - } - - let parsed: unknown; - try { - parsed = await res.json(); - } catch (error) { - console.error("Failed to parse suggested questions payload:", error); - return applyCachedQuestions(buildSuggestedQuestionFallbacks(3)); + if (!summaryRes.ok) { + const errorData = await summaryRes.json().catch(() => ({ error: "Unknown error" })); + setTakeawaysError(buildApiErrorMessage(errorData, "Failed to generate takeaways. Please try again.")); + return null; } - const questions = Array.isArray((parsed as any)?.questions) - ? (parsed as any).questions - .filter((item: unknown): item is string => typeof item === "string" && item.trim().length > 0) - .map((item: string) => item.trim()) - : []; - - const normalizedQuestions = questions.length > 0 - ? questions.slice(0, 3) - : buildSuggestedQuestionFallbacks(3); - - applyCachedQuestions(normalizedQuestions); + const summaryData = await summaryRes.json(); + const generatedTakeaways = summaryData.summaryContent; + setTakeawaysContent(generatedTakeaways); - // Update video analysis with suggested questions (requires auth + ownership) await backgroundOperation( - 'update-questions', + 'update-takeaways', async () => { const updateRes = await csrfFetch.post("/api/update-video-analysis", { videoId: extractedVideoId, - suggestedQuestions: normalizedQuestions + summary: generatedTakeaways }); - // 401/403 is expected for anonymous users or non-owners if (!updateRes.ok && updateRes.status !== 404 && updateRes.status !== 401 && updateRes.status !== 403) { - throw new Error('Failed to update suggested questions'); + throw new Error('Failed to update takeaways'); } } ); - return normalizedQuestions; + return generatedTakeaways; }, (error) => { - console.error("Failed to generate suggested questions:", error); + const isAbortError = + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: string }).name === "AbortError"; + + if (!isAbortError) { + setTakeawaysError(error.message || "Failed to generate takeaways. Please try again."); + } } - ); + ).finally(() => { + abortManager.current.cleanup('takeaways'); + setIsGeneratingTakeaways(false); + }); } catch (err) { setError( @@ -1423,19 +1063,11 @@ export default function AnalyzePage() { } finally { setPageState('IDLE'); setLoadingStage(null); - setGenerationStartTime(null); - setProcessingStartTime(null); - setIsGeneratingTakeaways(false); setSwitchingToLanguage(null); } }, [ - rateLimitInfo.remaining, storeCurrentVideoForAuth, videoId, - checkRateLimit, - user, - checkGenerationLimit, - redirectToAuthForLimit, forceRegenerate ]); @@ -1535,6 +1167,260 @@ export default function AnalyzePage() { } }, [isPlayingAll, requestPlayAll]); + const applyHighlightResponse = useCallback(( + topicsData: any, + sourceTranscript: TranscriptSegment[] + ): Topic[] => { + const rawTopics = Array.isArray(topicsData.topics) ? topicsData.topics : []; + const generatedTopics: Topic[] = hydrateTopicsWithTranscript(rawTopics, sourceTranscript); + const generatedThemes: string[] = Array.isArray(topicsData.themes) ? topicsData.themes : []; + const rawCandidates: TopicCandidate[] = Array.isArray(topicsData.topicCandidates) ? topicsData.topicCandidates : []; + const generatedCandidates: TopicCandidate[] = rawCandidates.map(candidate => ({ + ...candidate, + key: `${candidate.quote.timestamp}|${normalizeWhitespace(candidate.quote.text)}` + })); + + if (topicsData.videoDbId) { + setVideoDbId(topicsData.videoDbId); + } + + setTopics(generatedTopics); + setBaseTopics(generatedTopics); + + const initialKeys = new Set(); + generatedTopics.forEach(topic => { + if (topic.quote?.timestamp && topic.quote.text) { + initialKeys.add(`${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`); + } + }); + setUsedTopicKeys(initialKeys); + setThemeCandidateMap(prev => ({ + ...prev, + __default: generatedCandidates + })); + setSelectedTopic(null); + setThemes(generatedThemes); + + return generatedTopics; + }, []); + + const generateSuggestedQuestionsForTopics = useCallback(( + generatedTopics: Topic[], + sourceTranscript: TranscriptSegment[], + sourceVideoInfo: VideoInfo | null + ) => { + if (!videoId) return; + + backgroundOperation( + 'generate-questions', + async () => { + const res = await fetch("/api/suggested-questions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + transcript: sourceTranscript, + topics: generatedTopics, + videoTitle: sourceVideoInfo?.title, + language: sourceVideoInfo?.language + }), + }); + + const applyCachedQuestions = (questions: string[]) => { + if (questions.length === 0) { + return questions; + } + setCachedSuggestedQuestions(prev => { + if (prev && prev.length > 0) { + return prev; + } + return questions; + }); + return questions; + }; + + if (!res.ok) { + console.error("Failed to generate suggested questions:", res.status, res.statusText); + return applyCachedQuestions(buildSuggestedQuestionFallbacks(3)); + } + + let parsed: unknown; + try { + parsed = await res.json(); + } catch (error) { + console.error("Failed to parse suggested questions payload:", error); + return applyCachedQuestions(buildSuggestedQuestionFallbacks(3)); + } + + const questions = Array.isArray((parsed as any)?.questions) + ? (parsed as any).questions + .filter((item: unknown): item is string => typeof item === "string" && item.trim().length > 0) + .map((item: string) => item.trim()) + : []; + + const normalizedQuestions = questions.length > 0 + ? questions.slice(0, 3) + : buildSuggestedQuestionFallbacks(3); + + applyCachedQuestions(normalizedQuestions); + + await backgroundOperation( + 'update-questions', + async () => { + const updateRes = await csrfFetch.post("/api/update-video-analysis", { + videoId, + suggestedQuestions: normalizedQuestions + }); + + if (!updateRes.ok && updateRes.status !== 404 && updateRes.status !== 401 && updateRes.status !== 403) { + throw new Error('Failed to update suggested questions'); + } + } + ); + + return normalizedQuestions; + }, + (error) => { + console.error("Failed to generate suggested questions:", error); + } + ); + }, [videoId]); + + const confirmShareReady = useCallback((url: string) => { + backgroundOperation( + 'confirm-share-ready', + async () => { + if (!url) return false; + + const cacheCheck = await fetch("/api/check-video-cache", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }) + }); + + if (!cacheCheck.ok) { + return false; + } + + const cacheData = await cacheCheck.json(); + if (cacheData?.cached) { + setIsShareReady(true); + return true; + } + + return false; + }, + (error) => { + console.error("Failed to confirm cached analysis for sharing:", error); + } + ); + }, []); + + const handleGenerateHighlights = useCallback(async () => { + if (!videoId || transcript.length === 0 || isGeneratingHighlights) { + return; + } + + const cachedHighlightPayload = cachedHighlightPayloadRef.current; + if ( + !forceRegenerate && + cachedHighlightPayload?.videoId === videoId && + Array.isArray(cachedHighlightPayload.topics) && + cachedHighlightPayload.topics.length > 0 + ) { + setHighlightGenerationError(null); + setIsRateLimitError(false); + const generatedTopics = applyHighlightResponse(cachedHighlightPayload, transcript); + cachedHighlightPayloadRef.current = null; + if (!cachedSuggestedQuestions?.length) { + generateSuggestedQuestionsForTopics(generatedTopics, transcript, videoInfo); + } + return; + } + + const requestKey = 'highlights'; + const controller = abortManager.current.createController(requestKey); + setHighlightGenerationError(null); + setHighlightGenerationStartTime(Date.now()); + setIsGeneratingHighlights(true); + setIsRateLimitError(false); + + try { + const response = await fetch("/api/video-analysis", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + videoId, + videoInfo, + transcript, + includeCandidatePool: true, + mode, + forceRegenerate + }), + signal: controller.signal, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: "Unknown error" })); + const requiresAuth = Boolean((errorData as any)?.requiresAuth); + const authMessage = + typeof (errorData as any)?.message === "string" + ? (errorData as any).message + : undefined; + + if (requiresAuth || response.status === 401 || response.status === 403) { + redirectToAuthForLimit(authMessage, videoId); + return; + } + + if (response.status === 429) { + setIsRateLimitError(true); + checkRateLimit(); + } + + throw new Error(buildApiErrorMessage(errorData, "Failed to generate highlight reels")); + } + + const topicsData = await response.json(); + const generatedTopics = applyHighlightResponse(topicsData, transcript); + checkRateLimit(); + confirmShareReady(normalizedUrl); + generateSuggestedQuestionsForTopics(generatedTopics, transcript, videoInfo); + } catch (error) { + const isAbortError = + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: string }).name === "AbortError"; + + if (!isAbortError) { + setHighlightGenerationError( + normalizeErrorMessage( + error instanceof Error ? error.message : undefined, + "Failed to generate highlight reels. Please try again." + ) + ); + } + } finally { + abortManager.current.cleanup(requestKey); + setHighlightGenerationStartTime(null); + setIsGeneratingHighlights(false); + } + }, [ + videoId, + transcript, + isGeneratingHighlights, + videoInfo, + mode, + forceRegenerate, + cachedSuggestedQuestions, + redirectToAuthForLimit, + checkRateLimit, + applyHighlightResponse, + confirmShareReady, + normalizedUrl, + generateSuggestedQuestionsForTopics + ]); + useEffect(() => { selectedThemeRef.current = selectedTheme; }, [selectedTheme]); @@ -1733,14 +1619,22 @@ export default function AnalyzePage() { // Dynamically adjust right column height to match video container useEffect(() => { - const adjustRightColumnHeight = () => { - const videoContainer = document.getElementById("video-container"); - const rightColumnContainer = document.getElementById("right-column-container"); + let animationFrameId: number | null = null; - if (videoContainer && rightColumnContainer) { - const videoHeight = videoContainer.offsetHeight; - setTranscriptHeight(`${videoHeight}px`); + const adjustRightColumnHeight = () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); } + + animationFrameId = requestAnimationFrame(() => { + const videoContainer = document.getElementById("video-container"); + const rightColumnContainer = document.getElementById("right-column-container"); + + if (videoContainer && rightColumnContainer) { + const videoHeight = videoContainer.offsetHeight; + setTranscriptHeight(`${Math.max(videoHeight, 420)}px`); + } + }); }; // Initial adjustment @@ -1757,10 +1651,13 @@ export default function AnalyzePage() { } return () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } window.removeEventListener("resize", adjustRightColumnHeight); resizeObserver.disconnect(); }; - }, [videoId, topics]); // Re-run when video or topics change + }, [videoId, transcript.length, pageState]); // Re-run when the workspace first mounts or changes videos const [notes, setNotes] = useState([]); const [, setIsLoadingNotes] = useState(false); @@ -1945,14 +1842,12 @@ export default function AnalyzePage() {

{switchingToLanguage ? `Switching to ${getLanguageName(switchingToLanguage)}...` - : 'Analyzing video and generating highlight reels'} + : 'Loading video workspace'}

{!switchingToLanguage && (

{loadingStage === 'fetching' && 'Fetching transcript...'} {loadingStage === 'understanding' && 'Fetching transcript...'} - {loadingStage === 'generating' && `Creating highlight reels... (${elapsedTime} seconds)`} - {loadingStage === 'processing' && `Processing and matching quotes... (${processingElapsedTime} seconds)`}

)} @@ -2013,7 +1908,7 @@ export default function AnalyzePage() { )} - {videoId && topics.length > 0 && pageState === 'IDLE' && ( + {videoId && transcript.length > 0 && pageState === 'IDLE' && (
{error && (
@@ -2025,6 +1920,8 @@ export default function AnalyzePage() {
@@ -2081,7 +1982,7 @@ export default function AnalyzePage() {
{ + assert.match(pageSource, /const cachedHighlightPayloadRef = useRef\(null\)/); + assert.doesNotMatch(pageSource, /const \[cachedHighlightPayload, setCachedHighlightPayload\] = useState/); + + const cachedLoadStart = pageSource.indexOf('if (cacheData.cached)'); + assert.notEqual(cachedLoadStart, -1, 'Expected cached-video load branch to exist'); + + const cachedLoadSource = pageSource.slice(cachedLoadStart, cachedLoadStart + 8000); + assert.match(cachedLoadSource, /cachedHighlightPayloadRef\.current =/); + + const storeIndex = cachedLoadSource.indexOf('cachedHighlightPayloadRef.current ='); + const returnIndex = cachedLoadSource.indexOf('return; // Exit early - no need to fetch anything else'); + assert.ok(storeIndex > -1 && returnIndex > -1 && storeIndex < returnIndex); + + const beforeReturnSource = cachedLoadSource.slice(0, returnIndex); + assert.doesNotMatch(beforeReturnSource, /setTopics\(cacheData\.topics\)/); + assert.doesNotMatch(beforeReturnSource, /setBaseTopics\(cacheData\.topics\)/); +}); + +test('generate highlights reveals cached topics locally before calling video-analysis', () => { + const handleSource = getHandleGenerateHighlightsSource(); + + assert.match(handleSource, /const cachedHighlightPayload = cachedHighlightPayloadRef\.current/); + assert.match(handleSource, /applyHighlightResponse\(cachedHighlightPayload/); + assert.match(handleSource, /cachedHighlightPayloadRef\.current = null/); + + const cachedRevealIndex = handleSource.indexOf('applyHighlightResponse(cachedHighlightPayload'); + const fetchIndex = handleSource.indexOf('fetch("/api/video-analysis"'); + assert.ok(cachedRevealIndex > -1 && fetchIndex > -1 && cachedRevealIndex < fetchIndex); +}); + +test('cached reveal skips suggested question generation when cached questions exist', () => { + const handleSource = getHandleGenerateHighlightsSource(); + const cachedRevealIndex = handleSource.indexOf('applyHighlightResponse(cachedHighlightPayload'); + assert.notEqual(cachedRevealIndex, -1, 'Expected cached reveal branch to apply cached highlights'); + + const cachedRevealSource = handleSource.slice(cachedRevealIndex, handleSource.indexOf('const requestKey', cachedRevealIndex)); + assert.match(cachedRevealSource, /cachedSuggestedQuestions\?\.length/); + assert.match(cachedRevealSource, /generateSuggestedQuestionsForTopics\(generatedTopics, transcript, videoInfo\)/); + + const guardIndex = cachedRevealSource.indexOf('cachedSuggestedQuestions?.length'); + const generateIndex = cachedRevealSource.indexOf('generateSuggestedQuestionsForTopics(generatedTopics, transcript, videoInfo)'); + assert.ok(guardIndex > -1 && generateIndex > -1 && guardIndex < generateIndex); +}); diff --git a/app/analyze/__tests__/right-column-layout.test.ts b/app/analyze/__tests__/right-column-layout.test.ts new file mode 100644 index 0000000..6950361 --- /dev/null +++ b/app/analyze/__tests__/right-column-layout.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; + +const pageSource = readFileSync( + join(process.cwd(), 'app/analyze/[videoId]/page.tsx'), + 'utf8' +); + +test('right column height recalculates when transcript workspace becomes visible', () => { + const effectStart = pageSource.indexOf('// Dynamically adjust right column height'); + assert.notEqual(effectStart, -1, 'Expected right-column height effect to exist'); + + const effectSource = pageSource.slice(effectStart, effectStart + 1800); + + assert.match(effectSource, /requestAnimationFrame/); + assert.match(effectSource, /transcript\.length/); + assert.match(effectSource, /pageState/); +}); + +test('right column has a defensive minimum height before measurement completes', () => { + const containerStart = pageSource.indexOf('id="right-column-container"'); + assert.notEqual(containerStart, -1, 'Expected right-column container to exist'); + + const containerSource = pageSource.slice(containerStart, containerStart + 500); + assert.match(containerSource, /minHeight:\s*420/); +}); + +test('youtube player remounts when the analyzed video changes', () => { + const playerStart = pageSource.indexOf(' { + const requestSeekStart = pageSource.indexOf('const requestSeek = useCallback'); + assert.notEqual(requestSeekStart, -1, 'Expected requestSeek callback to exist'); + + const requestSeekSource = pageSource.slice(requestSeekStart, requestSeekStart + 300); + assert.match(requestSeekSource, /youtubePlayerRef\.current\?\.seekTo\(time\)/); + assert.match(requestSeekSource, /setPlaybackCommand\(\{ type: 'SEEK', time \}\)/); +}); diff --git a/app/analyze/__tests__/summary-background-flow.test.ts b/app/analyze/__tests__/summary-background-flow.test.ts new file mode 100644 index 0000000..c6b167f --- /dev/null +++ b/app/analyze/__tests__/summary-background-flow.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; + +const pageSource = readFileSync( + join(process.cwd(), 'app/analyze/[videoId]/page.tsx'), + 'utf8' +); + +function getOperationSource(operationName: string): string { + const marker = `'${operationName}'`; + const start = pageSource.indexOf(marker); + assert.notEqual(start, -1, `Expected to find background operation ${operationName}`); + + return pageSource.slice(start, start + 3500); +} + +function getSummaryFailureBranch(operationName: string): string { + const operation = getOperationSource(operationName); + const start = operation.indexOf('if (!summaryRes.ok)'); + assert.notEqual(start, -1, `Expected ${operationName} to guard failed summary responses`); + + let depth = 0; + for (let index = start; index < operation.length; index += 1) { + const char = operation[index]; + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return operation.slice(start, index + 1); + } + } + } + + assert.fail(`Expected to find end of failed summary branch in ${operationName}`); +} + +test('cached summary failure stays local to takeaways state', () => { + const failureBranch = getSummaryFailureBranch('generate-cached-takeaways'); + + assert.match(failureBranch, /setTakeawaysError\(/); + assert.match(failureBranch, /return null;/); + assert.doesNotMatch(failureBranch, /throw new Error/); +}); + +test('new-video summary failure stays local to takeaways state', () => { + const failureBranch = getSummaryFailureBranch('generate-takeaways'); + + assert.match(failureBranch, /setTakeawaysError\(/); + assert.match(failureBranch, /return null;/); + assert.doesNotMatch(failureBranch, /throw new Error/); +}); diff --git a/components/__tests__/highlights-panel.generate.test.tsx b/components/__tests__/highlights-panel.generate.test.tsx new file mode 100644 index 0000000..4e31dec --- /dev/null +++ b/components/__tests__/highlights-panel.generate.test.tsx @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { HighlightsPanel } from '@/components/highlights-panel'; + +const noop = () => {}; + +test('HighlightsPanel shows generate CTA instead of Play All before reels exist', () => { + const markup = renderToStaticMarkup( + + ); + + assert.match(markup, /Generate highlight reels/); + assert.doesNotMatch(markup, />Play All { + const markup = renderToStaticMarkup( + + ); + + assert.match(markup, /Analyzing video and generating highlight reels/); + assert.match(markup, /Creating highlight reels\.\.\. \(19 seconds\)/); +}); + +test('HighlightsPanel keeps Play All controls when reels exist', () => { + const markup = renderToStaticMarkup( + + ); + + assert.match(markup, />Play All { + assert.equal(shouldRenderHighlightTimeline(300, 0), false); + assert.equal(shouldRenderHighlightTimeline(0, 3), false); + assert.equal(shouldRenderHighlightTimeline(300, 3), true); +}); + +test('getYouTubePlayerVars includes origin when available', () => { + assert.deepEqual(getYouTubePlayerVars('http://localhost:3001'), { + autoplay: 0, + controls: 1, + modestbranding: 1, + rel: 0, + origin: 'http://localhost:3001', + }); +}); + +test('getYouTubePlayerElementId scopes the iframe container to the video', () => { + assert.equal(getYouTubePlayerElementId('abc123'), 'youtube-player-abc123'); + assert.equal(getYouTubePlayerElementId('video_with_symbols'), 'youtube-player-video_with_symbols'); +}); + +test('canExecutePlaybackCommand waits for player readiness', () => { + assert.equal(canExecutePlaybackCommand(null, false, { type: 'SEEK', time: 10 }), false); + assert.equal(canExecutePlaybackCommand({ seekTo: () => {} }, false, { type: 'SEEK', time: 10 }), false); + assert.equal(canExecutePlaybackCommand({ playVideo: () => {} }, true, { type: 'SEEK', time: 10 }), false); + assert.equal(canExecutePlaybackCommand({ seekTo: () => {} }, true, { type: 'SEEK', time: 10 }), true); + assert.equal(canExecutePlaybackCommand({ playVideo: () => {} }, true, { type: 'PLAY' }), true); +}); + +test('shouldPollPlayerTime waits for a ready player time API', () => { + assert.equal(shouldPollPlayerTime(null, false), false); + assert.equal(shouldPollPlayerTime({ getCurrentTime: () => 12 }, false), false); + assert.equal(shouldPollPlayerTime({ seekTo: () => {} }, true), false); + assert.equal(shouldPollPlayerTime({ getCurrentTime: () => 12 }, true), true); +}); + +test('shouldQueuePlaybackCommand keeps commands until player is ready', () => { + assert.equal(shouldQueuePlaybackCommand(null, false, null), false); + assert.equal(shouldQueuePlaybackCommand({ type: 'SEEK', time: 25 }, false, null), true); + assert.equal(shouldQueuePlaybackCommand({ type: 'SEEK', time: 25 }, false, { seekTo: () => {} }), true); + assert.equal(shouldQueuePlaybackCommand({ type: 'SEEK', time: 25 }, true, { playVideo: () => {} }), true); + assert.equal(shouldQueuePlaybackCommand({ type: 'SEEK', time: 25 }, true, { seekTo: () => {} }), false); +}); + +test('seekPlayerTo reports whether direct seeking was available', () => { + const calls: Array<[number, boolean]> = []; + const syncCalls: number[] = []; + const player = { + seekTo: (time: number, allowSeekAhead: boolean) => calls.push([time, allowSeekAhead]), + playVideo: () => {}, + }; + + assert.equal(seekPlayerTo(null, false, 15, (time) => syncCalls.push(time)), false); + assert.equal(seekPlayerTo({ playVideo: () => {} }, true, 15, (time) => syncCalls.push(time)), false); + assert.equal(seekPlayerTo(player, true, 42, (time) => syncCalls.push(time)), true); + assert.deepEqual(calls, [[42, true]]); + assert.deepEqual(syncCalls, [42]); +}); + +test('youtube player stores iframe readiness in a ref for direct seeking', () => { + assert.match(youtubePlayerSource, /const playerReadyRef = useRef\(false\)/); + assert.match(youtubePlayerSource, /playerReadyRef\.current = true/); + assert.match(youtubePlayerSource, /playerReadyRef\.current = false/); + assert.match(youtubePlayerSource, /seekPlayerTo\(playerRef\.current, playerReadyRef\.current, time, syncSeekTime\)/); +}); + +test('youtube player stores the constructed iframe player before onReady', () => { + assert.match(youtubePlayerSource, /const nextPlayer = new \(window as any\)\.YT\.Player/); + assert.match(youtubePlayerSource, /playerRef\.current = nextPlayer/); +}); + +test('youtube player replays a queued command when iframe readiness fires', () => { + const readyHandlerStart = youtubePlayerSource.indexOf('onReady:'); + assert.notEqual(readyHandlerStart, -1, 'Expected YouTube onReady handler to exist'); + + const readyHandlerSource = youtubePlayerSource.slice(readyHandlerStart, readyHandlerStart + 1200); + assert.match(readyHandlerSource, /pendingPlaybackCommandRef\.current/); + assert.match(readyHandlerSource, /executePlaybackCommandRef\.current/); + assert.match(readyHandlerSource, /onCommandExecuted\?\.\(\)/); +}); + +test('normalizeDensityBuckets handles zero-density timelines', () => { + assert.deepEqual(normalizeDensityBuckets([0, 0, 0]), [0, 0, 0]); + assert.deepEqual(normalizeDensityBuckets([0, 2, 4]), [0, 0.5, 1]); +}); diff --git a/components/highlights-panel.tsx b/components/highlights-panel.tsx index 31dc7f9..a6537da 100644 --- a/components/highlights-panel.tsx +++ b/components/highlights-panel.tsx @@ -1,18 +1,22 @@ "use client"; -import { useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import { Topic, TranscriptSegment, TranslationRequestHandler } from "@/lib/types"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { VideoProgressBar } from "@/components/video-progress-bar"; import { formatDuration, cn } from "@/lib/utils"; -import { Play, Pause, Loader2 } from "lucide-react"; +import { Play, Pause, Loader2, Sparkles } from "lucide-react"; // Default English labels const DEFAULT_LABELS = { playAll: "Play All", stop: "Stop", - generatingYourReels: "Generating your reels...", + generatingYourReels: "Creating highlight reels...", + analyzingAndGenerating: "Analyzing video and generating highlight reels", + generateHighlightReels: "Generate highlight reels", + generateDescription: "AI will scan the full transcript and create highlight reels - the most insightful moments organized by topic.", + tryAgain: "Try again", }; interface HighlightsPanelProps { @@ -31,6 +35,10 @@ interface HighlightsPanelProps { videoId?: string; selectedLanguage?: string | null; onRequestTranslation?: TranslationRequestHandler; + onGenerateHighlights?: () => void; + isGeneratingHighlights?: boolean; + highlightGenerationElapsedTime?: number; + highlightGenerationError?: string | null; } export function HighlightsPanel({ @@ -48,6 +56,10 @@ export function HighlightsPanel({ videoId, selectedLanguage = null, onRequestTranslation, + onGenerateHighlights, + isGeneratingHighlights = false, + highlightGenerationElapsedTime = 0, + highlightGenerationError = null, }: HighlightsPanelProps) { // Translation state const [translatedLabels, setTranslatedLabels] = useState(DEFAULT_LABELS); @@ -66,6 +78,10 @@ export function HighlightsPanel({ onRequestTranslation(DEFAULT_LABELS.playAll, `ui_highlights:playAll:${selectedLanguage}`), onRequestTranslation(DEFAULT_LABELS.stop, `ui_highlights:stop:${selectedLanguage}`), onRequestTranslation(DEFAULT_LABELS.generatingYourReels, `ui_highlights:generatingYourReels:${selectedLanguage}`), + onRequestTranslation(DEFAULT_LABELS.analyzingAndGenerating, `ui_highlights:analyzingAndGenerating:${selectedLanguage}`), + onRequestTranslation(DEFAULT_LABELS.generateHighlightReels, `ui_highlights:generateHighlightReels:${selectedLanguage}`), + onRequestTranslation(DEFAULT_LABELS.generateDescription, `ui_highlights:generateDescription:${selectedLanguage}`), + onRequestTranslation(DEFAULT_LABELS.tryAgain, `ui_highlights:tryAgain:${selectedLanguage}`), ]); if (!isCancelled) { @@ -73,6 +89,10 @@ export function HighlightsPanel({ playAll: translations[0], stop: translations[1], generatingYourReels: translations[2], + analyzingAndGenerating: translations[3], + generateHighlightReels: translations[4], + generateDescription: translations[5], + tryAgain: translations[6], }); } }; @@ -89,26 +109,71 @@ export function HighlightsPanel({ }; }, [selectedLanguage, onRequestTranslation]); + const hasTopics = topics.length > 0; + const showGenerateState = !hasTopics && onGenerateHighlights; + const isGenerating = isGeneratingHighlights || isLoadingThemeTopics; + const highlightGenerationLabel = + isGeneratingHighlights + ? `${translatedLabels.generatingYourReels} (${highlightGenerationElapsedTime} seconds)` + : translatedLabels.generatingYourReels; + return (
- onTopicSelect(topic)} - onPlayTopic={onPlayTopic} - transcript={transcript} - isLoadingThemeTopics={isLoadingThemeTopics} - videoId={videoId} - selectedLanguage={selectedLanguage} - onRequestTranslation={onRequestTranslation} - /> + {showGenerateState && isGeneratingHighlights ? ( +
+ +

+ {translatedLabels.analyzingAndGenerating} +

+

+ {highlightGenerationLabel} +

+
+ ) : showGenerateState ? ( +
+ +

+ {translatedLabels.generateHighlightReels} +

+

+ {translatedLabels.generateDescription} +

+ {highlightGenerationError && !isGeneratingHighlights && ( +

+ {highlightGenerationError} +

+ )} + +
+ ) : ( + onTopicSelect(topic)} + onPlayTopic={onPlayTopic} + transcript={transcript} + isLoadingThemeTopics={isLoadingThemeTopics} + videoId={videoId} + selectedLanguage={selectedLanguage} + onRequestTranslation={onRequestTranslation} + /> + )}
@@ -116,7 +181,8 @@ export function HighlightsPanel({ {formatDuration(currentTime)} / {formatDuration(videoDuration)}
-
+ {hasTopics && ( +
-
+
+ )}
{/* Loading overlay */} - {isLoadingThemeTopics && ( + {isGenerating && hasTopics && (

- {translatedLabels.generatingYourReels} + {highlightGenerationLabel}

)} diff --git a/components/topic-card.tsx b/components/topic-card.tsx index bb149ef..e200e65 100644 --- a/components/topic-card.tsx +++ b/components/topic-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import { Topic, TranslationRequestHandler } from "@/lib/types"; import { formatDuration, getTopicHSLColor } from "@/lib/utils"; import { cn } from "@/lib/utils"; diff --git a/components/video-progress-bar.tsx b/components/video-progress-bar.tsx index 21de2a2..13598d5 100644 --- a/components/video-progress-bar.tsx +++ b/components/video-progress-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef } from "react"; +import React, { useRef } from "react"; import { Topic, TranscriptSegment, TranslationRequestHandler } from "@/lib/types"; import { getTopicHSLColor } from "@/lib/utils"; import { TopicCard } from "@/components/topic-card"; @@ -21,6 +21,14 @@ interface VideoProgressBarProps { onRequestTranslation?: TranslationRequestHandler; } +export function normalizeDensityBuckets(density: number[]) { + const maxDensity = Math.max(...density); + if (maxDensity === 0) { + return density; + } + return density.map((d) => d / maxDensity); +} + export function VideoProgressBar({ videoDuration, currentTime, @@ -75,8 +83,7 @@ export function VideoProgressBar({ }); }); - const maxDensity = Math.max(...density); - return density.map((d) => d / maxDensity); + return normalizeDensityBuckets(density); }; const density = calculateDensity(); diff --git a/components/youtube-player.tsx b/components/youtube-player.tsx index 48bce3a..100cedf 100644 --- a/components/youtube-player.tsx +++ b/components/youtube-player.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react"; import { Topic, TranscriptSegment, PlaybackCommand, TranslationRequestHandler } from "@/lib/types"; import { formatDuration } from "@/lib/utils"; import { Card } from "@/components/ui/card"; @@ -28,7 +28,103 @@ interface YouTubePlayerProps { onRequestTranslation?: TranslationRequestHandler; } -export function YouTubePlayer({ +export type YouTubePlayerHandle = { + seekTo: (time: number) => boolean; +}; + +type YouTubePlayerVars = { + autoplay: 0; + controls: 1; + modestbranding: 1; + rel: 0; + origin?: string; +}; + +export function getYouTubePlayerVars(origin?: string | null): YouTubePlayerVars { + return { + autoplay: 0, + controls: 1, + modestbranding: 1, + rel: 0, + ...(origin ? { origin } : {}), + }; +} + +export function getYouTubePlayerElementId(videoId: string) { + return `youtube-player-${videoId}`; +} + +export function shouldRenderHighlightTimeline(videoDuration: number, topicCount: number) { + return videoDuration > 0 && topicCount > 0; +} + +function hasPlayerMethod(player: unknown, methodName: string) { + return Boolean( + player && + typeof (player as Record)[methodName] === 'function' + ); +} + +export function canExecutePlaybackCommand( + player: unknown, + playerReady: boolean, + command?: PlaybackCommand | null +) { + if (!player || !playerReady) return false; + + switch (command?.type) { + case 'SEEK': + case 'PLAY_TOPIC': + case 'PLAY_SEGMENT': + case 'PLAY_CITATIONS': + case 'PLAY_ALL': + return hasPlayerMethod(player, 'seekTo'); + case 'PLAY': + return hasPlayerMethod(player, 'playVideo'); + case 'PAUSE': + return hasPlayerMethod(player, 'pauseVideo'); + default: + return true; + } +} + +export function shouldPollPlayerTime(player: unknown, playerReady: boolean) { + return Boolean( + playerReady && + player && + typeof (player as { getCurrentTime?: unknown }).getCurrentTime === 'function' + ); +} + +export function shouldQueuePlaybackCommand( + command: PlaybackCommand | null | undefined, + playerReady: boolean, + player: unknown +) { + return Boolean(command && !canExecutePlaybackCommand(player, playerReady, command)); +} + +export function seekPlayerTo( + player: unknown, + playerReady: boolean, + time: number, + onSeeked?: (time: number) => void +) { + if (!canExecutePlaybackCommand(player, playerReady, { type: 'SEEK', time })) { + return false; + } + + const seekablePlayer = player as { + seekTo: (time: number, allowSeekAhead: boolean) => void; + playVideo?: () => void; + }; + seekablePlayer.seekTo(time, true); + onSeeked?.(time); + seekablePlayer.playVideo?.(); + return true; +} + +function YouTubePlayerComponent({ videoId, selectedTopic, onTimeUpdate, @@ -46,18 +142,124 @@ export function YouTubePlayer({ onDurationChange, selectedLanguage = null, onRequestTranslation, -}: YouTubePlayerProps) { +}: YouTubePlayerProps, ref: Ref) { const playerRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [citationReelSegmentIndex, setCitationReelSegmentIndex] = useState(0); const [currentTime, setCurrentTime] = useState(0); const [videoDuration, setVideoDuration] = useState(0); const [playerReady, setPlayerReady] = useState(false); + const playerReadyRef = useRef(false); const timeUpdateIntervalRef = useRef(null); - const isSeekingRef = useRef(false); const isPlayingAllRef = useRef(false); const playAllIndexRef = useRef(0); const topicsRef = useRef([]); + const pendingPlaybackCommandRef = useRef(null); + const executePlaybackCommandRef = useRef<((command: PlaybackCommand) => boolean) | null>(null); + const playerElementId = getYouTubePlayerElementId(videoId); + + const syncSeekTime = useCallback((time: number) => { + setCurrentTime(time); + onTimeUpdate?.(time); + }, [onTimeUpdate]); + + useImperativeHandle(ref, () => ({ + seekTo: (time: number) => seekPlayerTo(playerRef.current, playerReadyRef.current, time, syncSeekTime), + }), [syncSeekTime]); + + const executePlaybackCommand = useCallback((command: PlaybackCommand) => { + if (!canExecutePlaybackCommand(playerRef.current, playerReadyRef.current, command)) { + return false; + } + + switch (command.type) { + case 'SEEK': + if (command.time !== undefined) { + return seekPlayerTo(playerRef.current, playerReadyRef.current, command.time, syncSeekTime); + } + return false; + + case 'PLAY_TOPIC': + if (command.topic) { + const topic = command.topic; + onTopicSelect?.(topic); + if (topic.segments.length > 0) { + const startTime = topic.segments[0].start; + playerRef.current.seekTo(startTime, true); + syncSeekTime(startTime); + if (command.autoPlay) { + playerRef.current.playVideo(); + } + return true; + } + } + return false; + + case 'PLAY_SEGMENT': + if (command.segment) { + playerRef.current.seekTo(command.segment.start, true); + syncSeekTime(command.segment.start); + playerRef.current.playVideo(); + return true; + } + return false; + + case 'PLAY_CITATIONS': + if (command.citations && command.citations.length > 0) { + // Create citation reel topic + const citationReel: Topic = { + id: `citation-reel-${Date.now()}`, + title: "Cited Clips", + description: "Playing all clips cited in the AI response", + duration: command.citations.reduce((total, c) => total + (c.end - c.start), 0), + segments: command.citations.map(c => ({ + start: c.start, + end: c.end, + text: c.text, + startSegmentIdx: c.startSegmentIdx, + endSegmentIdx: c.endSegmentIdx, + startCharOffset: c.startCharOffset, + endCharOffset: c.endCharOffset, + })), + isCitationReel: true, + autoPlay: true, + }; + onTopicSelect?.(citationReel); + playerRef.current.seekTo(command.citations[0].start, true); + syncSeekTime(command.citations[0].start); + if (command.autoPlay) { + playerRef.current.playVideo(); + } + return true; + } + return false; + + case 'PLAY_ALL': + if (topics.length > 0) { + // Play All state is already set in requestPlayAll. + // Just select the first topic and start playing. + onTopicSelect?.(topics[0], true); + const startTime = topics[0].segments[0].start; + playerRef.current.seekTo(startTime, true); + syncSeekTime(startTime); + if (command.autoPlay) { + playerRef.current.playVideo(); + } + return true; + } + return false; + + case 'PLAY': + playerRef.current.playVideo(); + return true; + + case 'PAUSE': + playerRef.current.pauseVideo(); + return true; + } + }, [onTopicSelect, syncSeekTime, topics]); + + executePlaybackCommandRef.current = executePlaybackCommand; // Keep refs in sync with state useEffect(() => { @@ -75,100 +277,105 @@ export function YouTubePlayer({ useEffect(() => { setVideoDuration(0); setCurrentTime(0); + playerReadyRef.current = false; onDurationChange?.(0); if (!videoId) return; let mounted = true; - let player: any = null; + + const startTimeUpdateInterval = () => { + if (timeUpdateIntervalRef.current) { + clearInterval(timeUpdateIntervalRef.current); + } + + let lastUpdateTime = 0; + timeUpdateIntervalRef.current = setInterval(() => { + if (!shouldPollPlayerTime(playerRef.current, true)) return; + + const time = playerRef.current.getCurrentTime(); + + // Always update internal current time for progress bar and timer + setCurrentTime(time); + + // Handle Play All mode auto-transitions + if (isPlayingAllRef.current && topicsRef.current.length > 0) { + const currentIndex = playAllIndexRef.current; + const currentTopic = topicsRef.current[currentIndex]; + if (currentTopic && currentTopic.segments.length > 0) { + const segment = currentTopic.segments[0]; + + // Check if we've reached the end of the current segment + if (time >= segment.end) { + const isLastTopic = currentIndex >= topicsRef.current.length - 1; + if (isLastTopic) { + // End Play All mode + setIsPlayingAll?.(false); + isPlayingAllRef.current = false; + playerRef.current.pauseVideo(); + } else { + // Advance to the next topic + const nextIndex = currentIndex + 1; + playAllIndexRef.current = nextIndex; + setPlayAllIndex?.(nextIndex); + } + } + } + } + + // Throttle external updates to reduce re-renders (update every 500ms instead of 100ms) + const timeDiff = Math.abs(time - lastUpdateTime); + if (timeDiff >= 0.5) { + lastUpdateTime = time; + onTimeUpdate?.(time); + } + }, 100); + }; const initializePlayer = () => { // Only create player if component still mounted and no player exists if (!mounted || playerRef.current) return; - player = new (window as any).YT.Player("youtube-player", { + const nextPlayer = new (window as any).YT.Player(playerElementId, { videoId: videoId, - playerVars: { - autoplay: 0, - controls: 1, - modestbranding: 1, - rel: 0, - }, + playerVars: getYouTubePlayerVars(window.location.origin), events: { onReady: (event: { target: any }) => { if (!mounted) return; - playerRef.current = player; + playerReadyRef.current = true; const duration = event.target.getDuration(); setVideoDuration(duration); onDurationChange?.(duration); setPlayerReady(true); + startTimeUpdateInterval(); onPlayerReady?.(); + + const pendingCommand = pendingPlaybackCommandRef.current; + if (pendingCommand) { + setTimeout(() => { + if (!mounted || pendingPlaybackCommandRef.current !== pendingCommand) return; + + const executed = executePlaybackCommandRef.current?.(pendingCommand) ?? false; + if (executed) { + pendingPlaybackCommandRef.current = null; + onCommandExecuted?.(); + } + }, 50); + } }, onStateChange: (event: { data: number; target: any }) => { if (!mounted) return; const playing = event.data === 1; setIsPlaying(playing); - - if (playing) { - // Start time update interval with throttling - if (timeUpdateIntervalRef.current) { - clearInterval(timeUpdateIntervalRef.current); - } - - let lastUpdateTime = 0; - timeUpdateIntervalRef.current = setInterval(() => { - // Skip updates while seeking to prevent feedback loops - if (isSeekingRef.current) return; - - if (playerRef.current?.getCurrentTime) { - const time = playerRef.current.getCurrentTime(); - - // Always update internal current time for progress bar - setCurrentTime(time); - - // Handle Play All mode auto-transitions - if (isPlayingAllRef.current && topicsRef.current.length > 0) { - const currentIndex = playAllIndexRef.current; - const currentTopic = topicsRef.current[currentIndex]; - if (currentTopic && currentTopic.segments.length > 0) { - const segment = currentTopic.segments[0]; - - // Check if we've reached the end of the current segment - if (time >= segment.end) { - const isLastTopic = currentIndex >= topicsRef.current.length - 1; - if (isLastTopic) { - // End Play All mode - setIsPlayingAll?.(false); - isPlayingAllRef.current = false; - playerRef.current.pauseVideo(); - } else { - // Advance to the next topic - const nextIndex = currentIndex + 1; - playAllIndexRef.current = nextIndex; - setPlayAllIndex?.(nextIndex); - } - } - } - } - - // Throttle external updates to reduce re-renders (update every 500ms instead of 100ms) - const timeDiff = Math.abs(time - lastUpdateTime); - if (timeDiff >= 0.5) { - lastUpdateTime = time; - onTimeUpdate?.(time); - } - } - }, 100); - } else { - // Clear time update interval - if (timeUpdateIntervalRef.current) { - clearInterval(timeUpdateIntervalRef.current); - timeUpdateIntervalRef.current = null; - } + }, + onError: (event: { data: number }) => { + if (process.env.NODE_ENV !== 'production') { + console.warn('YouTube player error', { code: event.data, videoId }); } }, }, }); + playerRef.current = nextPlayer; }; // Check if YouTube API is already loaded @@ -194,6 +401,8 @@ export function YouTubePlayer({ return () => { mounted = false; setPlayerReady(false); + playerReadyRef.current = false; + pendingPlaybackCommandRef.current = null; if (playerRef.current) { try { @@ -208,98 +417,36 @@ export function YouTubePlayer({ timeUpdateIntervalRef.current = null; } }; - }, [videoId, onDurationChange, onTimeUpdate, setIsPlayingAll, setPlayAllIndex, onPlayerReady]); + }, [videoId, playerElementId, onCommandExecuted, onDurationChange, onTimeUpdate, setIsPlayingAll, setPlayAllIndex, onPlayerReady]); // Centralized command executor useEffect(() => { - if (!playbackCommand || !playerRef.current || !playerReady) return; + const command = playbackCommand ?? pendingPlaybackCommandRef.current; + if (!command) return; - const executeCommand = () => { - switch (playbackCommand.type) { - case 'SEEK': - if (playbackCommand.time !== undefined) { - playerRef.current.seekTo(playbackCommand.time, true); - playerRef.current.playVideo(); - } - break; - - case 'PLAY_TOPIC': - if (playbackCommand.topic) { - const topic = playbackCommand.topic; - onTopicSelect?.(topic); - if (topic.segments.length > 0) { - playerRef.current.seekTo(topic.segments[0].start, true); - if (playbackCommand.autoPlay) { - playerRef.current.playVideo(); - } - } - } - break; - - case 'PLAY_SEGMENT': - if (playbackCommand.segment) { - playerRef.current.seekTo(playbackCommand.segment.start, true); - playerRef.current.playVideo(); - } - break; - - case 'PLAY_CITATIONS': - if (playbackCommand.citations && playbackCommand.citations.length > 0) { - // Create citation reel topic - const citationReel: Topic = { - id: `citation-reel-${Date.now()}`, - title: "Cited Clips", - description: "Playing all clips cited in the AI response", - duration: playbackCommand.citations.reduce((total, c) => total + (c.end - c.start), 0), - segments: playbackCommand.citations.map(c => ({ - start: c.start, - end: c.end, - text: c.text, - startSegmentIdx: c.startSegmentIdx, - endSegmentIdx: c.endSegmentIdx, - startCharOffset: c.startCharOffset, - endCharOffset: c.endCharOffset, - })), - isCitationReel: true, - autoPlay: true, - }; - onTopicSelect?.(citationReel); - playerRef.current.seekTo(playbackCommand.citations[0].start, true); - if (playbackCommand.autoPlay) { - playerRef.current.playVideo(); - } - } - break; - - case 'PLAY_ALL': - if (topics.length > 0) { - // Play All state is already set in requestPlayAll - // Just select the first topic and start playing - onTopicSelect?.(topics[0], true); // Pass true for fromPlayAll - playerRef.current.seekTo(topics[0].segments[0].start, true); - if (playbackCommand.autoPlay) { - playerRef.current.playVideo(); - } - } - break; - - case 'PLAY': - playerRef.current.playVideo(); - break; + if (shouldQueuePlaybackCommand(command, playerReadyRef.current, playerRef.current)) { + pendingPlaybackCommandRef.current = command; + return; + } - case 'PAUSE': - playerRef.current.pauseVideo(); - break; + const executeCommand = () => { + const executed = executePlaybackCommand(command); + if (!executed) { + pendingPlaybackCommandRef.current = command; + return; } // Clear command after execution + if (pendingPlaybackCommandRef.current === command) { + pendingPlaybackCommandRef.current = null; + } onCommandExecuted?.(); }; // Execute with small delay to ensure player stability const timeoutId = setTimeout(executeCommand, 50); return () => clearTimeout(timeoutId); - }, [playbackCommand, playerReady, topics, onCommandExecuted, onTopicSelect, setIsPlayingAll, setPlayAllIndex]); + }, [executePlaybackCommand, playbackCommand, playerReady, onCommandExecuted]); // Reset segment index when topic changes and auto-play if needed useEffect(() => { @@ -331,10 +478,11 @@ export function YouTubePlayer({ // Seek to the start of the topic's segment and play const segment = currentTopic.segments[0]; playerRef.current.seekTo(segment.start, true); + syncSeekTime(segment.start); playerRef.current.playVideo(); } }, 100); - }, [isPlayingAll, playAllIndex, playerReady, topics, onTopicSelect]); + }, [isPlayingAll, playAllIndex, playerReady, topics, onTopicSelect, syncSeekTime]); // Monitor playback to handle citation reel transitions useEffect(() => { @@ -364,6 +512,7 @@ export function YouTubePlayer({ // Seek to the start of the next segment playerRef.current.seekTo(nextSegment.start, true); + syncSeekTime(nextSegment.start); } else { // This was the last segment, pause the video playerRef.current.pauseVideo(); @@ -382,7 +531,7 @@ export function YouTubePlayer({ clearInterval(monitoringInterval); }; } - }, [selectedTopic, isPlaying, isPlayingAll, citationReelSegmentIndex]); + }, [selectedTopic, isPlaying, isPlayingAll, citationReelSegmentIndex, syncSeekTime]); const playTopic = (topic: Topic) => { if (!playerRef.current || !topic || topic.segments.length === 0) return; @@ -395,6 +544,7 @@ export function YouTubePlayer({ // Seek to the start of the single segment and play const segment = topic.segments[0]; playerRef.current.seekTo(segment.start, true); + syncSeekTime(segment.start); playerRef.current.playVideo(); }; @@ -402,7 +552,7 @@ export function YouTubePlayer({ const handleSeek = (time: number) => { playerRef.current?.seekTo(time, true); - setCurrentTime(time); + syncSeekTime(time); }; @@ -411,14 +561,14 @@ export function YouTubePlayer({
{renderControls && (
- {videoDuration > 0 && ( + {shouldRenderHighlightTimeline(videoDuration, topics.length) && ( ); } + +export const YouTubePlayer = forwardRef(YouTubePlayerComponent); +YouTubePlayer.displayName = 'YouTubePlayer';