| import React, { useEffect, useRef, useCallback, useState } from "react"; |
| import { useTVModeStore, selectCurrentScene, selectSceneDuration, selectIsLastScene, selectNextSceneReady, selectCurrentSceneImageReady, selectIsWaitingForImage, selectNextScene, } from "../../stores/tvModeStore"; |
| import { TVModePlayer } from "./TVModePlayer"; |
| import { TVModeControls } from "./TVModeControls"; |
| import { TVModeSettings } from "./TVModeSettings"; |
| import { TVModeEndScreen } from "./TVModeEndScreen"; |
| import { TVModeChapterTransition } from "./TVModeChapterTransition"; |
| import { useTTSPlayback } from "../../hooks/useTTSPlayback"; |
| export const TVModeContainer = ({ onGenerateNext, onEnsureImage, onContinueChapter, onSyncOutline, }) => { |
| const containerRef = useRef(null); |
| const controlsTimerRef = useRef(null); |
| const sceneTimerRef = useRef(null); |
| const prefetchInFlightRef = useRef(false); |
| const [showChapterTransition, setShowChapterTransition] = useState(false); |
| const [waitingForTTS, setWaitingForTTS] = useState(false); |
| const [waitingForNextScene, setWaitingForNextScene] = useState(false); |
| const { isActive, isPlaying, currentSceneIndex, scenes, controlsVisible, showSettings, showEndScreen, isPrefetching, isStoryComplete, isLoadingNextChapter, chapterNumber, settings, exitTVMode, togglePlay, nextScene, prevScene, goToScene, showControls, hideControls, setShowSettings, addScene, setPrefetching, setPrefetchError, setFullscreen, setLoadingNextChapter, startNextChapter, } = useTVModeStore(); |
| const currentScene = useTVModeStore(selectCurrentScene); |
| const nextSceneData = useTVModeStore(selectNextScene); |
| const sceneDuration = useTVModeStore(selectSceneDuration); |
| const isLastScene = useTVModeStore(selectIsLastScene); |
| const nextSceneReady = useTVModeStore(selectNextSceneReady); |
| const currentImageReady = useTVModeStore(selectCurrentSceneImageReady); |
| const isWaitingForImage = useTVModeStore(selectIsWaitingForImage); |
| |
| |
| const handleSceneNarrationEnd = useCallback(() => { |
| console.log("[TV Mode] TTS narration finished for current scene"); |
| setWaitingForTTS(false); |
| if (isLastScene) { |
| |
| if (scenes.length < 24 && !isPrefetching && !isStoryComplete && settings.autoGenerateScenes) { |
| |
| console.log("[TV Mode] Last scene finished, waiting for new scene generation..."); |
| setWaitingForNextScene(true); |
| } |
| else if (!isStoryComplete && !isPrefetching) { |
| nextScene(); |
| } |
| } |
| else if (nextSceneReady) { |
| |
| console.log("[TV Mode] Next scene ready, advancing..."); |
| setWaitingForNextScene(false); |
| nextScene(); |
| } |
| else { |
| |
| console.log("[TV Mode] Waiting for next scene to be ready (image generating)..."); |
| setWaitingForNextScene(true); |
| } |
| }, [isLastScene, scenes.length, isPrefetching, isStoryComplete, nextSceneReady, nextScene, settings.autoGenerateScenes]); |
| |
| const { ttsEnabled, setTTSEnabled, isSpeaking, isPaused: ttsPaused, isSupported: ttsSupported, voices, voiceConfig, setVoiceConfig, stopSpeaking, pauseSpeaking, resumeSpeaking, } = useTTSPlayback({ |
| isActive, |
| isPlaying, |
| currentScene, |
| currentSceneIndex, |
| scenes, |
| onSceneNarrationEnd: handleSceneNarrationEnd, |
| }); |
| |
| useEffect(() => { |
| if (isActive && containerRef.current) { |
| const enterFullscreen = async () => { |
| try { |
| await containerRef.current?.requestFullscreen(); |
| setFullscreen(true); |
| } |
| catch (err) { |
| console.warn("Could not enter fullscreen:", err); |
| } |
| }; |
| enterFullscreen(); |
| } |
| return () => { |
| if (document.fullscreenElement) { |
| document.exitFullscreen().catch(() => { }); |
| } |
| }; |
| }, [isActive, setFullscreen]); |
| |
| useEffect(() => { |
| const handleFullscreenChange = () => { |
| if (!document.fullscreenElement && isActive) { |
| setFullscreen(false); |
| } |
| }; |
| document.addEventListener("fullscreenchange", handleFullscreenChange); |
| return () => { |
| document.removeEventListener("fullscreenchange", handleFullscreenChange); |
| }; |
| }, [isActive, setFullscreen]); |
| |
| const resetControlsTimer = useCallback(() => { |
| if (controlsTimerRef.current) { |
| clearTimeout(controlsTimerRef.current); |
| } |
| showControls(); |
| if (isPlaying && !showSettings) { |
| controlsTimerRef.current = window.setTimeout(() => { |
| hideControls(); |
| }, settings.autoHideDelay); |
| } |
| }, [isPlaying, showSettings, settings.autoHideDelay, showControls, hideControls]); |
| |
| useEffect(() => { |
| if (ttsEnabled && isSpeaking && currentScene?.narration) { |
| setWaitingForTTS(true); |
| } |
| }, [ttsEnabled, isSpeaking, currentScene?.narration]); |
| |
| useEffect(() => { |
| if (!ttsEnabled || !isPlaying) { |
| setWaitingForTTS(false); |
| } |
| }, [ttsEnabled, isPlaying]); |
| |
| useEffect(() => { |
| setWaitingForTTS(false); |
| }, [currentSceneIndex]); |
| |
| useEffect(() => { |
| setWaitingForNextScene(false); |
| }, [currentSceneIndex]); |
| |
| useEffect(() => { |
| if (!waitingForNextScene || !isPlaying) |
| return; |
| if (nextSceneReady) { |
| |
| console.log("[TV Mode] Next scene now ready, advancing..."); |
| setWaitingForNextScene(false); |
| nextScene(); |
| } |
| }, [waitingForNextScene, nextSceneReady, isPlaying, nextScene]); |
| |
| useEffect(() => { |
| if (sceneTimerRef.current) { |
| clearTimeout(sceneTimerRef.current); |
| sceneTimerRef.current = null; |
| } |
| |
| if (isStoryComplete || showChapterTransition) { |
| return; |
| } |
| |
| |
| if (ttsEnabled && currentScene?.narration) { |
| |
| if (isSpeaking || waitingForTTS) { |
| console.log("[TV Mode] TTS driving flow, narration in progress..."); |
| return; |
| } |
| |
| if (waitingForNextScene) { |
| console.log("[TV Mode] TTS finished, waiting for next scene to be ready..."); |
| return; |
| } |
| |
| return; |
| } |
| |
| |
| if (!currentImageReady && currentScene) { |
| return; |
| } |
| if (isPlaying && currentScene && !showEndScreen) { |
| sceneTimerRef.current = window.setTimeout(() => { |
| if (isLastScene) { |
| |
| if (scenes.length < 24 && !isPrefetching && !isStoryComplete && settings.autoGenerateScenes) { |
| |
| handlePrefetch(); |
| } |
| else if (!isStoryComplete && !isPrefetching) { |
| |
| nextScene(); |
| } |
| |
| } |
| else if (nextSceneReady) { |
| |
| nextScene(); |
| } |
| |
| |
| }, sceneDuration); |
| } |
| return () => { |
| if (sceneTimerRef.current) { |
| clearTimeout(sceneTimerRef.current); |
| } |
| }; |
| |
| }, [isPlaying, currentSceneIndex, currentScene, sceneDuration, isLastScene, showEndScreen, nextSceneReady, currentImageReady, isStoryComplete, showChapterTransition, isPrefetching, scenes.length, ttsEnabled, isSpeaking, waitingForTTS, waitingForNextScene, settings.autoGenerateScenes]); |
| |
| useEffect(() => { |
| if (!currentScene || !onEnsureImage) |
| return; |
| const hasImage = Boolean(currentScene.image_url || currentScene.image); |
| if (!hasImage && (currentScene.imageStatus === "pending" || currentScene.imageStatus === "generating")) { |
| onEnsureImage(currentScene); |
| } |
| }, [currentScene?.idx, currentScene?.imageStatus, onEnsureImage]); |
| |
| useEffect(() => { |
| if (!nextSceneData || !onEnsureImage) |
| return; |
| const hasImage = Boolean(nextSceneData.image_url || nextSceneData.image); |
| if (!hasImage && (nextSceneData.imageStatus === "pending" || nextSceneData.imageStatus === "generating")) { |
| onEnsureImage(nextSceneData); |
| } |
| }, [nextSceneData?.idx, nextSceneData?.imageStatus, onEnsureImage]); |
| |
| const handlePrefetch = useCallback(async () => { |
| |
| if (prefetchInFlightRef.current || isPrefetching || isStoryComplete || scenes.length >= 24) |
| return; |
| prefetchInFlightRef.current = true; |
| setPrefetching(true); |
| setPrefetchError(null); |
| try { |
| console.log("[TV Mode] Starting scene generation..."); |
| const newScene = await onGenerateNext(); |
| if (newScene) { |
| addScene(newScene); |
| console.log("[TV Mode] New scene generated successfully:", newScene.idx); |
| |
| if (onSyncOutline) { |
| try { |
| await onSyncOutline(); |
| console.log("[TV Mode] Outline synced after scene generation"); |
| } |
| catch (syncError) { |
| console.warn("[TV Mode] Failed to sync outline:", syncError); |
| } |
| } |
| } |
| |
| } |
| catch (error) { |
| console.error("[TV Mode] Scene generation failed:", error); |
| setPrefetchError(error.message || "Failed to generate next scene"); |
| } |
| finally { |
| setPrefetching(false); |
| prefetchInFlightRef.current = false; |
| } |
| }, [isPrefetching, isStoryComplete, scenes.length, onGenerateNext, addScene, setPrefetching, setPrefetchError, onSyncOutline]); |
| |
| useEffect(() => { |
| if (isPlaying && isLastScene && scenes.length < 24 && !isPrefetching && !isStoryComplete && settings.autoGenerateScenes) { |
| handlePrefetch(); |
| } |
| }, [isPlaying, isLastScene, scenes.length, isPrefetching, isStoryComplete, handlePrefetch, settings.autoGenerateScenes]); |
| |
| useEffect(() => { |
| if (!isStoryComplete || !settings.sagaMode || !onContinueChapter) { |
| setShowChapterTransition(false); |
| return; |
| } |
| |
| if (!isLastScene) { |
| setShowChapterTransition(false); |
| return; |
| } |
| |
| setShowChapterTransition(true); |
| }, [isStoryComplete, settings.sagaMode, isLastScene, onContinueChapter]); |
| |
| const handleContinueToNextChapter = useCallback(async () => { |
| if (!onContinueChapter || isLoadingNextChapter) |
| return; |
| console.log(`[TV Mode] Chapter ${chapterNumber} complete, loading next chapter...`); |
| setLoadingNextChapter(true); |
| try { |
| const chapterData = await onContinueChapter(); |
| if (chapterData) { |
| setShowChapterTransition(false); |
| startNextChapter(chapterData.sessionId, chapterData.title, chapterData.scenes, chapterData.chapterNumber); |
| console.log(`[TV Mode] Started chapter ${chapterData.chapterNumber}: ${chapterData.title}`); |
| } |
| else { |
| |
| setLoadingNextChapter(false); |
| setShowChapterTransition(false); |
| } |
| } |
| catch (error) { |
| console.error('[TV Mode] Failed to continue to next chapter:', error); |
| setLoadingNextChapter(false); |
| setShowChapterTransition(false); |
| } |
| }, [onContinueChapter, isLoadingNextChapter, chapterNumber, setLoadingNextChapter, startNextChapter]); |
| |
| const handleCancelChapterTransition = useCallback(() => { |
| setShowChapterTransition(false); |
| exitTVMode(); |
| }, [exitTVMode]); |
| |
| useEffect(() => { |
| const handleKeyDown = (e) => { |
| |
| if (showSettings && e.target instanceof HTMLInputElement) |
| return; |
| switch (e.key) { |
| case "Escape": |
| if (showSettings) { |
| setShowSettings(false); |
| } |
| else { |
| exitTVMode(); |
| } |
| break; |
| case " ": |
| e.preventDefault(); |
| togglePlay(); |
| resetControlsTimer(); |
| break; |
| case "ArrowRight": |
| case "l": |
| case "L": |
| nextScene(); |
| resetControlsTimer(); |
| break; |
| case "ArrowLeft": |
| case "j": |
| case "J": |
| prevScene(); |
| resetControlsTimer(); |
| break; |
| case "f": |
| case "F": |
| if (document.fullscreenElement) { |
| document.exitFullscreen(); |
| } |
| else { |
| containerRef.current?.requestFullscreen(); |
| } |
| break; |
| default: |
| resetControlsTimer(); |
| } |
| }; |
| if (isActive) { |
| window.addEventListener("keydown", handleKeyDown); |
| } |
| return () => { |
| window.removeEventListener("keydown", handleKeyDown); |
| }; |
| }, [isActive, showSettings, exitTVMode, togglePlay, nextScene, prevScene, resetControlsTimer, setShowSettings]); |
| |
| const handleMouseMove = useCallback(() => { |
| resetControlsTimer(); |
| }, [resetControlsTimer]); |
| const handleClick = useCallback((e) => { |
| |
| if (e.target.closest(".tv-controls")) |
| return; |
| resetControlsTimer(); |
| }, [resetControlsTimer]); |
| |
| const touchStartX = useRef(0); |
| const handleTouchStart = useCallback((e) => { |
| touchStartX.current = e.touches[0].clientX; |
| }, []); |
| const handleTouchEnd = useCallback((e) => { |
| const deltaX = e.changedTouches[0].clientX - touchStartX.current; |
| const minSwipeDistance = 50; |
| if (Math.abs(deltaX) > minSwipeDistance) { |
| if (deltaX > 0) { |
| prevScene(); |
| } |
| else { |
| nextScene(); |
| } |
| } |
| resetControlsTimer(); |
| }, [prevScene, nextScene, resetControlsTimer]); |
| if (!isActive) |
| return null; |
| return (<div ref={containerRef} className="tv-mode-container" onMouseMove={handleMouseMove} onClick={handleClick} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}> |
| <TVModePlayer scene={currentScene} isTransitioning={false} transitionDuration={settings.transitionDuration} isImageLoading={!currentImageReady || isWaitingForImage} isPrefetching={isPrefetching}/> |
| |
| <TVModeControls visible={controlsVisible} isPlaying={isPlaying} currentIndex={currentSceneIndex} totalScenes={scenes.length} isPrefetching={isPrefetching} isWaitingForNextImage={!isLastScene && !nextSceneReady && isPlaying} onTogglePlay={togglePlay} onPrev={prevScene} onNext={() => { |
| if (isLastScene && scenes.length < 24) { |
| handlePrefetch(); |
| } |
| nextScene(); |
| }} onExit={exitTVMode} onGoToScene={goToScene} onShowSettings={() => setShowSettings(true)} ttsEnabled={ttsEnabled} ttsSupported={ttsSupported} isSpeaking={isSpeaking} onToggleTTS={() => setTTSEnabled(!ttsEnabled)}/> |
| |
| {showSettings && (<TVModeSettings onClose={() => setShowSettings(false)} ttsEnabled={ttsEnabled} setTTSEnabled={setTTSEnabled} ttsSupported={ttsSupported} voices={voices} voiceConfig={voiceConfig} setVoiceConfig={setVoiceConfig}/>)} |
| |
| {/* Chapter Transition Screen (Saga Mode) */} |
| {showChapterTransition && settings.sagaMode && onContinueChapter && (<TVModeChapterTransition onContinue={handleContinueToNextChapter} onCancel={handleCancelChapterTransition} nextChapterTitle={`Chapter ${chapterNumber + 1}`} countdown={5}/>)} |
| |
| {/* End Screen (when not in saga mode or saga mode disabled) */} |
| {showEndScreen && !showChapterTransition && (<TVModeEndScreen onRestart={() => goToScene(0)} onExit={exitTVMode} onContinue={scenes.length < 24 ? handlePrefetch : undefined}/>)} |
| |
| <style>{` |
| .tv-mode-container { |
| position: fixed; |
| inset: 0; |
| background: #000; |
| z-index: 9999; |
| display: flex; |
| flex-direction: column; |
| user-select: none; |
| overflow: hidden; |
| } |
| |
| @media (prefers-reduced-motion: reduce) { |
| .tv-mode-container * { |
| animation: none !important; |
| transition: none !important; |
| } |
| } |
| `}</style> |
| </div>); |
| }; |
| export default TVModeContainer; |
|
|