| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useCallback, useEffect, useRef, useState } from "react"; |
| import { |
| ArrowLeft, ArrowRight, Maximize, Minimize, |
| Pause, Play, RotateCcw, SkipBack, SkipForward, |
| Volume2, VolumeX, |
| } from "lucide-react"; |
| import type { InteractiveApi } from "./api"; |
| import type { CatalogItemView, Experience, ResolveResult, SceneJobView } from "./types"; |
| import { InteractiveApiError } from "./types"; |
| import { useAsyncResource, useToast } from "./ui"; |
| import { |
| fadeKey, |
| useFadeOnSceneChange, |
| useScenePreload, |
| } from "./scenePreload"; |
|
|
|
|
| const SKIP_SECONDS = 10; |
|
|
|
|
| export interface StandardPlayerProps { |
| api: InteractiveApi; |
| sessionId: string; |
| |
| |
| |
| experienceId?: string; |
| experience?: Experience | null; |
| scene: SceneJobView | null; |
| onExit: () => void; |
| onResolved: (resolved: ResolveResult, action: CatalogItemView) => void; |
| } |
|
|
|
|
| export function StandardPlayer({ |
| api, sessionId, experienceId, experience, scene, onExit, onResolved, |
| }: StandardPlayerProps) { |
| const videoRef = useRef<HTMLVideoElement | null>(null); |
| const containerRef = useRef<HTMLDivElement | null>(null); |
| const toast = useToast(); |
|
|
| const [playing, setPlaying] = useState(true); |
| const [muted, setMuted] = useState(true); |
| const [currentTime, setCurrentTime] = useState(0); |
| const [duration, setDuration] = useState(0); |
| const [isFullscreen, setIsFullscreen] = useState(false); |
| const [decisionOpen, setDecisionOpen] = useState(false); |
| const [firing, setFiring] = useState<string | null>(null); |
| |
| |
| |
| |
| |
| |
| |
| const [mediaError, setMediaError] = useState(false); |
| const [retryNonce, setRetryNonce] = useState(0); |
| const spokenKeyRef = useRef<string>(""); |
| const [speechPlaying, setSpeechPlaying] = useState(false); |
| const [awaitingSpeechGate, setAwaitingSpeechGate] = useState(false); |
|
|
| const _readBool = (v: unknown): boolean | null => { |
| if (typeof v === "boolean") return v; |
| const s = String(v ?? "").trim().toLowerCase(); |
| if (!s) return null; |
| if (["1", "true", "yes", "on"].includes(s)) return true; |
| if (["0", "false", "no", "off"].includes(s)) return false; |
| return null; |
| }; |
|
|
| const baseUrl = (() => { |
| if (!scene?.asset_url) return ""; |
| |
| |
| |
| |
| |
| |
| return scene.asset_url; |
| })(); |
| const url = retryNonce > 0 && baseUrl |
| ? `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}_t=${retryNonce}` |
| : baseUrl; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const currentNodeId = String(scene?.id || "") |
| .replace(/^initial_/, "") |
| .trim(); |
| useScenePreload(api, experienceId || "", currentNodeId); |
|
|
| |
| |
| |
| const fade = useFadeOnSceneChange(fadeKey(scene?.id, url)); |
| const mediaHint = String(scene?.media_kind || "").toLowerCase(); |
| |
| |
| |
| |
| |
| |
| |
| const _IMG_RE = /\.(png|jpe?g|webp|gif|avif)([?&#]|$)/i; |
| const _VID_RE = /\.(mp4|webm|mov|mkv|m4v)([?&#]|$)/i; |
| const isImage = |
| mediaHint === "image" || (!!url && _IMG_RE.test(url)); |
| const isVideo = |
| mediaHint === "video" || (!!url && _VID_RE.test(url)); |
| const defaultDuration = Math.max(1, Number(scene?.duration_sec || 5)); |
| const narrationText = String(scene?.subtitles || scene?.narration || scene?.prompt || "").trim(); |
|
|
| |
| |
| |
| |
| const ap = (experience?.audience_profile || {}) as Record<string, unknown>; |
| const globalTtsEnabled = (() => { |
| try { |
| const lsToggle = localStorage.getItem("homepilot_tts_enabled") !== "false"; |
| const svcEnabled = (window as any)?.SpeechService?.isTTSEnabled?.() ?? true; |
| return lsToggle && svcEnabled; |
| } catch { |
| return false; |
| } |
| })(); |
| const customTtsEnabled = |
| _readBool(ap.tts_enabled) ?? |
| _readBool(ap.scene_tts_enabled); |
| const speechEnabled = (customTtsEnabled ?? globalTtsEnabled) && !muted; |
|
|
| |
| |
| |
| |
| const catalog = useAsyncResource<CatalogItemView[]>( |
| (signal) => api.getCatalog(sessionId, signal), |
| [api, sessionId], |
| ); |
|
|
| |
| |
| const choices = (catalog.data || []) |
| .filter((c) => c.unlocked) |
| .sort((a, b) => (a.ordinal || 0) - (b.ordinal || 0)); |
|
|
| useEffect(() => { |
| setDecisionOpen(false); |
| setAwaitingSpeechGate(false); |
| setSpeechPlaying(false); |
| setMediaError(false); |
| setRetryNonce(0); |
| setCurrentTime(0); |
| setDuration(defaultDuration); |
| setPlaying(true); |
| }, [scene?.id, defaultDuration]); |
|
|
| const _openDecisionWhenReady = useCallback(() => { |
| if (choices.length <= 0) return; |
| if (speechEnabled && narrationText && speechPlaying) { |
| setAwaitingSpeechGate(true); |
| return; |
| } |
| setDecisionOpen(true); |
| }, [choices.length, narrationText, speechEnabled, speechPlaying]); |
|
|
| |
| useEffect(() => { |
| if (!isVideo) return undefined; |
| const v = videoRef.current; |
| if (!v) return; |
| const onTime = () => setCurrentTime(v.currentTime || 0); |
| const onDur = () => setDuration(v.duration || 0); |
| const onEnd = () => { |
| setPlaying(false); |
| _openDecisionWhenReady(); |
| }; |
| const onPlay = () => setPlaying(true); |
| const onPause = () => setPlaying(false); |
| v.addEventListener("timeupdate", onTime); |
| v.addEventListener("loadedmetadata", onDur); |
| v.addEventListener("ended", onEnd); |
| v.addEventListener("play", onPlay); |
| v.addEventListener("pause", onPause); |
| return () => { |
| v.removeEventListener("timeupdate", onTime); |
| v.removeEventListener("loadedmetadata", onDur); |
| v.removeEventListener("ended", onEnd); |
| v.removeEventListener("play", onPlay); |
| v.removeEventListener("pause", onPause); |
| }; |
| }, [_openDecisionWhenReady, isVideo]); |
|
|
| |
| |
| useEffect(() => { |
| if (!isImage || !url || mediaError) return undefined; |
| const maxSec = Math.max(1, Number(scene?.duration_sec || 5)); |
| setDuration(maxSec); |
| if (!playing || decisionOpen) return undefined; |
| const t = window.setInterval(() => { |
| setCurrentTime((prev) => { |
| const next = Math.min(maxSec, prev + 0.1); |
| if (next >= maxSec) { |
| setPlaying(false); |
| _openDecisionWhenReady(); |
| } |
| return next; |
| }); |
| }, 100); |
| return () => window.clearInterval(t); |
| }, [_openDecisionWhenReady, decisionOpen, mediaError, isImage, playing, scene?.duration_sec, url]); |
|
|
| |
| useEffect(() => { |
| if (videoRef.current) videoRef.current.muted = muted; |
| }, [muted]); |
|
|
| |
| |
| useEffect(() => { |
| const svc = (window as any)?.SpeechService; |
| if (!svc || typeof svc.speak !== "function") return; |
| if (!speechEnabled) { |
| svc.stopSpeaking?.(); |
| setSpeechPlaying(false); |
| if (awaitingSpeechGate && choices.length > 0) { |
| setAwaitingSpeechGate(false); |
| setDecisionOpen(true); |
| } |
| return; |
| } |
| if (!scene?.id || !narrationText) return; |
| const key = `${scene.id}::${narrationText}`; |
| if (spokenKeyRef.current === key) return; |
| spokenKeyRef.current = key; |
| svc.stopSpeaking?.(); |
| svc.speak(narrationText, { |
| onStart: () => setSpeechPlaying(true), |
| onEnd: () => setSpeechPlaying(false), |
| onError: () => { |
| setSpeechPlaying(false); |
| }, |
| }); |
| }, [awaitingSpeechGate, choices.length, narrationText, scene?.id, speechEnabled]); |
|
|
| useEffect(() => { |
| if (!awaitingSpeechGate) return; |
| if (speechPlaying) return; |
| setAwaitingSpeechGate(false); |
| if (choices.length > 0) setDecisionOpen(true); |
| }, [awaitingSpeechGate, choices.length, speechPlaying]); |
|
|
| |
| useEffect(() => { |
| const onChange = () => setIsFullscreen(!!document.fullscreenElement); |
| document.addEventListener("fullscreenchange", onChange); |
| return () => document.removeEventListener("fullscreenchange", onChange); |
| }, []); |
|
|
| |
| const togglePlay = useCallback(() => { |
| if (isImage) { |
| setPlaying((p) => !p); |
| return; |
| } |
| const v = videoRef.current; |
| if (!v) return; |
| if (v.paused) v.play().catch(() => { }); |
| else v.pause(); |
| }, [isImage]); |
|
|
| const skipBy = useCallback((delta: number) => { |
| if (isImage) { |
| setCurrentTime((prev) => { |
| const target = Math.max(0, Math.min(duration, prev + delta)); |
| if (target >= duration) _openDecisionWhenReady(); |
| return target; |
| }); |
| return; |
| } |
| const v = videoRef.current; |
| if (!v) return; |
| const target = Math.max( |
| 0, |
| Math.min((v.duration || 0) - 0.1, (v.currentTime || 0) + delta), |
| ); |
| v.currentTime = target; |
| }, [_openDecisionWhenReady, duration, isImage]); |
|
|
| const restart = useCallback(() => { |
| if (isImage) { |
| setCurrentTime(0); |
| setPlaying(true); |
| setDecisionOpen(false); |
| return; |
| } |
| const v = videoRef.current; |
| if (!v) return; |
| v.currentTime = 0; |
| v.play().catch(() => { }); |
| setDecisionOpen(false); |
| }, [isImage]); |
|
|
| const toggleFullscreen = useCallback(() => { |
| const el = containerRef.current; |
| if (!el) return; |
| if (!document.fullscreenElement) { |
| el.requestFullscreen?.().catch(() => undefined); |
| } else { |
| document.exitFullscreen?.().catch(() => undefined); |
| } |
| }, []); |
|
|
| const onSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { |
| if (isImage) { |
| const pctVal = Number(e.target.value); |
| setCurrentTime(duration * (pctVal / 100)); |
| return; |
| } |
| const v = videoRef.current; |
| if (!v) return; |
| const pct = Number(e.target.value); |
| v.currentTime = (v.duration || 0) * (pct / 100); |
| }, [duration, isImage]); |
|
|
| const fireChoice = useCallback(async (action: CatalogItemView) => { |
| if (!action.unlocked || firing) return; |
| setFiring(action.id); |
| try { |
| const resolved = await api.resolveTurn(sessionId, { action_id: action.id }); |
| if (resolved.decision.decision !== "allow") { |
| toast.toast({ |
| variant: "warning", |
| title: "Choice blocked", |
| message: resolved.decision.message || resolved.decision.reason_code, |
| }); |
| } else { |
| onResolved(resolved, action); |
| setDecisionOpen(false); |
| } |
| } catch (err) { |
| const e = err as InteractiveApiError; |
| toast.toast({ |
| variant: "error", |
| title: "Couldn't pick that path", |
| message: e.message || "Try again.", |
| }); |
| } finally { |
| setFiring(null); |
| } |
| }, [api, firing, onResolved, sessionId, toast]); |
|
|
| const pct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; |
|
|
| return ( |
| <div |
| ref={containerRef} |
| className="relative w-full h-full bg-black text-white flex items-center justify-center" |
| > |
| {/* |
| * Fade-transition wrapper. Wraps every media element + the |
| * error / pending fallback so the whole stage cross-fades |
| * on scene change. CSS transition matches the JS-side |
| * duration via ``transitionMs`` to keep them in sync. |
| */} |
| <div |
| className="absolute inset-0 flex items-center justify-center" |
| style={{ |
| opacity: fade.opacity, |
| transition: `opacity ${fade.transitionMs}ms ease-out`, |
| willChange: "opacity", |
| }} |
| > |
| {mediaError ? ( |
| // Shared error UI for both <img> and <video> failures. The |
| // Retry button bumps ``retryNonce`` which appends a |
| // cache-busting ``?_t=N`` query string to the asset URL β |
| // resetting just the ``mediaError`` flag wasn't enough |
| // because the browser would happily re-use the cached 404. |
| <div className="w-full h-full flex flex-col items-center justify-center text-white/75 text-sm gap-3 px-6 text-center"> |
| <div className="text-base font-medium"> |
| {isVideo ? "Couldn't play this video scene." : "Couldn't load this scene's image."} |
| </div> |
| <div className="text-xs text-white/50 max-w-md"> |
| The asset host may be unreachable. If you're running |
| ComfyUI locally, make sure it's still running. If you |
| switched between machines, your operator may need to |
| enable ``INTERACTIVE_PROXY_ASSETS`` so the backend |
| streams assets through itself. |
| </div> |
| <button |
| type="button" |
| onClick={() => { |
| setMediaError(false); |
| setRetryNonce((n) => n + 1); |
| setCurrentTime(0); |
| }} |
| className="px-3 py-1.5 rounded-md border border-white/30 bg-black/30 hover:bg-black/50" |
| > |
| Retry |
| </button> |
| </div> |
| ) : isVideo ? ( |
| <video |
| ref={videoRef} |
| src={url} |
| autoPlay |
| muted={muted} |
| playsInline |
| className="w-full h-full object-contain" |
| onClick={togglePlay} |
| onError={() => setMediaError(true)} |
| /> |
| ) : isImage ? ( |
| <img |
| src={url} |
| className="w-full h-full object-contain animate-[pulse_12s_ease-in-out_infinite]" |
| alt={scene?.prompt || "Scene"} |
| onError={() => setMediaError(true)} |
| /> |
| ) : ( |
| <div className="w-full h-full flex items-center justify-center text-white/60 text-sm"> |
| {scene?.status === "rendering" || scene?.status === "pending" |
| ? "Generating sceneβ¦" |
| : "Scene not available yet."} |
| </div> |
| )} |
| </div>{/* end fade wrapper */} |
|
|
| {/* |
| * Visual-novel caption overlay. Reads scene.subtitles first |
| * (author override) then scene.narration (planner) β falls |
| * silent if neither is present. Lives below the controls and |
| * above the seek bar so a viewer's eyes don't have to leave |
| * the bottom of the frame to read along. The "Standard" player |
| * is YouTube-scope entertainment + visual-novel reading; the |
| * caption is what makes it feel like a manga/visual novel. |
| * |
| * Visibility persists in localStorage so the user's preference |
| * (captions on / off) survives reloads. |
| */} |
| <CaptionOverlay scene={scene} /> |
|
|
| <TopBar onRestart={restart} onNext={() => setDecisionOpen(true)} onExit={onExit} /> |
| <CenterControls |
| playing={playing} |
| onPlay={togglePlay} |
| onBack={() => skipBy(-SKIP_SECONDS)} |
| onForward={() => skipBy(SKIP_SECONDS)} |
| /> |
| <BottomBar |
| playing={playing} |
| muted={muted} |
| onTogglePlay={togglePlay} |
| onToggleMute={() => setMuted((m) => !m)} |
| pct={pct} |
| currentTime={currentTime} |
| duration={duration} |
| onSeek={onSeek} |
| isFullscreen={isFullscreen} |
| onToggleFullscreen={toggleFullscreen} |
| /> |
|
|
| {decisionOpen && choices.length > 0 && ( |
| <DecisionModal |
| choices={choices} |
| firing={firing} |
| onPick={fireChoice} |
| onClose={() => setDecisionOpen(false)} |
| /> |
| )} |
| </div> |
| ); |
| } |
|
|
|
|
| // ββ Controls ββββββββββββββββββββββββββββββββββββββββββββββββββββ |
|
|
| function TopBar({ |
| onRestart, onNext, onExit, |
| }: { |
| onRestart: () => void; |
| onNext: () => void; |
| onExit: () => void; |
| }) { |
| return ( |
| <div className="absolute top-0 inset-x-0 z-20 px-4 py-3 flex items-center justify-between pointer-events-none"> |
| <div className="flex items-center gap-2 pointer-events-auto"> |
| <IconBtn onClick={onExit} label="Back to projects"> |
| <ArrowLeft className="w-4 h-4" /> |
| </IconBtn> |
| <IconBtn onClick={onRestart} label="Restart scene"> |
| <RotateCcw className="w-4 h-4" /> |
| </IconBtn> |
| </div> |
| <div className="pointer-events-auto"> |
| <IconBtn onClick={onNext} label="Go to next decision"> |
| <ArrowRight className="w-4 h-4" /> |
| </IconBtn> |
| </div> |
| </div> |
| ); |
| } |
|
|
|
|
| function CenterControls({ |
| playing, onPlay, onBack, onForward, |
| }: { |
| playing: boolean; |
| onPlay: () => void; |
| onBack: () => void; |
| onForward: () => void; |
| }) { |
| // Center playback + scrubbing overlay. Mirrors the reference |
| // screenshot's three-icon cluster: back-10, play/pause, fwd-10. |
| return ( |
| <div className="absolute inset-0 z-10 flex items-center justify-center gap-8 pointer-events-none"> |
| <button |
| type="button" |
| onClick={onBack} |
| aria-label={`Back ${SKIP_SECONDS} seconds`} |
| className="pointer-events-auto w-14 h-14 rounded-full bg-black/40 hover:bg-black/60 border border-white/30 flex items-center justify-center backdrop-blur-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white" |
| > |
| <span className="relative inline-flex items-center justify-center"> |
| <SkipBack className="w-6 h-6" /> |
| <span className="absolute text-[10px] font-bold pointer-events-none">10</span> |
| </span> |
| </button> |
| <button |
| type="button" |
| onClick={onPlay} |
| aria-label={playing ? "Pause" : "Play"} |
| className="pointer-events-auto w-20 h-20 rounded-full bg-black/40 hover:bg-black/60 border border-white/30 flex items-center justify-center backdrop-blur-sm transition-transform hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-white" |
| > |
| {playing |
| ? <Pause className="w-9 h-9 fill-current" /> |
| : <Play className="w-9 h-9 fill-current ml-1" />} |
| </button> |
| <button |
| type="button" |
| onClick={onForward} |
| aria-label={`Forward ${SKIP_SECONDS} seconds`} |
| className="pointer-events-auto w-14 h-14 rounded-full bg-black/40 hover:bg-black/60 border border-white/30 flex items-center justify-center backdrop-blur-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white" |
| > |
| <span className="relative inline-flex items-center justify-center"> |
| <SkipForward className="w-6 h-6" /> |
| <span className="absolute text-[10px] font-bold pointer-events-none">10</span> |
| </span> |
| </button> |
| </div> |
| ); |
| } |
|
|
|
|
| function BottomBar({ |
| playing, muted, onTogglePlay, onToggleMute, pct, currentTime, duration, |
| onSeek, isFullscreen, onToggleFullscreen, |
| }: { |
| playing: boolean; |
| muted: boolean; |
| onTogglePlay: () => void; |
| onToggleMute: () => void; |
| pct: number; |
| currentTime: number; |
| duration: number; |
| onSeek: (e: React.ChangeEvent<HTMLInputElement>) => void; |
| isFullscreen: boolean; |
| onToggleFullscreen: () => void; |
| }) { |
| return ( |
| <div className="absolute bottom-0 inset-x-0 z-20 px-4 py-3 bg-gradient-to-t from-black/80 to-transparent"> |
| <input |
| type="range" |
| min={0} |
| max={100} |
| step={0.1} |
| value={pct} |
| onChange={onSeek} |
| aria-label="Seek" |
| className="w-full h-1.5 accent-[#ec4899] cursor-pointer" |
| style={{ |
| background: `linear-gradient(to right, #ec4899 ${pct}%, rgba(255,255,255,0.25) ${pct}%)`, |
| }} |
| /> |
| <div className="mt-2 flex items-center gap-3 text-[12px] text-white/85"> |
| <IconBtn onClick={onTogglePlay} label={playing ? "Pause" : "Play"}> |
| {playing |
| ? <Pause className="w-4 h-4 fill-current" /> |
| : <Play className="w-4 h-4 fill-current" />} |
| </IconBtn> |
| <IconBtn onClick={onToggleMute} label={muted ? "Unmute" : "Mute"}> |
| {muted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />} |
| </IconBtn> |
| <span className="tabular-nums"> |
| {fmtTime(currentTime)} / {fmtTime(duration)} |
| </span> |
| <span className="flex-1" /> |
| <IconBtn onClick={onToggleFullscreen} label={isFullscreen ? "Exit fullscreen" : "Fullscreen"}> |
| {isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />} |
| </IconBtn> |
| </div> |
| </div> |
| ); |
| } |
|
|
|
|
| function IconBtn({ |
| children, onClick, label, |
| }: { children: React.ReactNode; onClick: () => void; label: string }) { |
| return ( |
| <button |
| type="button" |
| onClick={onClick} |
| aria-label={label} |
| className="w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white" |
| > |
| {children} |
| </button> |
| ); |
| } |
|
|
|
|
| // ββ Decision modal βββββββββββββββββββββββββββββββββββββββββββββ |
|
|
| function DecisionModal({ |
| choices, firing, onPick, onClose, |
| }: { |
| choices: CatalogItemView[]; |
| firing: string | null; |
| onPick: (c: CatalogItemView) => void; |
| onClose: () => void; |
| }) { |
| // Centered card with "WHAT TO DO NEXT?" header + 2β4 image-card |
| // choices. The backdrop dims + blurs the video so the decision |
| // point has the visual weight the reference screenshot shows. |
| return ( |
| <div |
| role="dialog" |
| aria-modal="true" |
| aria-label="What to do next?" |
| className="absolute inset-0 z-30 flex items-center justify-center p-6 bg-black/70 backdrop-blur-sm" |
| onClick={onClose} |
| > |
| <div |
| className="w-full max-w-3xl rounded-3xl bg-black/60 border border-white/15 backdrop-blur-md px-6 py-5" |
| onClick={(e) => e.stopPropagation()} |
| > |
| <div className="text-[11px] uppercase tracking-[0.18em] text-white/60 text-center mb-4"> |
| What to do next? |
| </div> |
| <div |
| className={[ |
| "grid gap-4", |
| choices.length >= 3 ? "sm:grid-cols-3 grid-cols-2" : "grid-cols-2", |
| ].join(" ")} |
| > |
| {choices.slice(0, 4).map((c) => ( |
| <ChoiceCard |
| key={c.id} |
| choice={c} |
| firing={firing === c.id} |
| onClick={() => onPick(c)} |
| /> |
| ))} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
|
|
| // ββ Caption overlay (visual-novel surface) ββββββββββββββββββββββββ |
| // |
| // Visual-novel / manga-style caption that sits ABOVE the bottom |
| // controls and renders the scene's narration. Reads scene.subtitles |
| // first (author override) then scene.narration (planner). Hidden |
| // when both are empty. |
| // |
| // The toggle (eye icon) lives at the right edge so the user can hide |
| // captions for a clean view; the choice persists in localStorage |
| // (key: ``homepilot_captions``) so a user who hides them once stays |
| // hidden across reloads. |
|
|
| const CAPTIONS_STORAGE_KEY = "homepilot_captions"; |
|
|
| function _readCaptionsEnabled(): boolean { |
| try { |
| const raw = globalThis.localStorage?.getItem(CAPTIONS_STORAGE_KEY); |
| // Default ON β captions are the visual-novel feature; only honor |
| // an explicit "off" preference written by the toggle below. |
| return raw !== "0"; |
| } catch { |
| return true; |
| } |
| } |
|
|
| function _writeCaptionsEnabled(next: boolean): void { |
| try { |
| globalThis.localStorage?.setItem(CAPTIONS_STORAGE_KEY, next ? "1" : "0"); |
| } catch { |
| /* private browsing / quota β non-fatal */ |
| } |
| } |
|
|
| function CaptionOverlay({ scene }: { scene: SceneJobView | null }) { |
| const [enabled, setEnabled] = useState<boolean>(() => _readCaptionsEnabled()); |
|
|
| // Re-read from localStorage on mount so a different tab that |
| // toggled the preference takes effect here on next render. |
| useEffect(() => { |
| const sync = () => setEnabled(_readCaptionsEnabled()); |
| globalThis.addEventListener?.("storage", sync); |
| return () => globalThis.removeEventListener?.("storage", sync); |
| }, []); |
|
|
| const text = String(scene?.subtitles || scene?.narration || "").trim(); |
| // Hide when nothing to show OR the user toggled captions off. The |
| // toggle button stays mounted (small dot at the right edge) so the |
| // user can re-enable without leaving the player. |
| const hasText = text.length > 0; |
|
|
| const onToggle = useCallback(() => { |
| setEnabled((prev) => { |
| const next = !prev; |
| _writeCaptionsEnabled(next); |
| return next; |
| }); |
| }, []); |
|
|
| return ( |
| <div |
| className={[ |
| "absolute left-0 right-0 z-10 px-4 pointer-events-none", |
| // Sit ABOVE the bottom bar. BottomBar lives at bottom-0 with |
| // ~3rem of internal padding + the seek slider β 5.5rem keeps |
| // the caption clear of the slider and safely above controls. |
| "bottom-[5.5rem] sm:bottom-[6rem]", |
| ].join(" ")} |
| aria-hidden={!hasText || !enabled} |
| > |
| <div className="max-w-3xl mx-auto flex items-end gap-2"> |
| {hasText && enabled ? ( |
| <div |
| className={[ |
| "pointer-events-auto flex-1 min-w-0", |
| "rounded-xl border border-white/15 bg-black/65 backdrop-blur-md", |
| "px-4 py-3 text-white/95 text-[15px] leading-relaxed", |
| "shadow-[0_8px_32px_-12px_rgba(0,0,0,0.6)]", |
| // Visual-novel tone: serif feels storybook-y; system |
| // font-stack falls through cleanly when serif unavailable. |
| "font-serif", |
| ].join(" ")} |
| role="region" |
| aria-label="Scene caption" |
| aria-live="polite" |
| > |
| {text} |
| </div> |
| ) : ( |
| <span className="flex-1" aria-hidden /> |
| )} |
| <button |
| type="button" |
| onClick={onToggle} |
| aria-label={enabled ? "Hide captions" : "Show captions"} |
| aria-pressed={enabled} |
| className={[ |
| "pointer-events-auto shrink-0", |
| "w-9 h-9 rounded-full bg-black/55 hover:bg-black/75", |
| "border border-white/20 text-white/85", |
| "flex items-center justify-center backdrop-blur-sm", |
| "transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white", |
| ].join(" ")} |
| > |
| {/* Use a tiny inline glyph so we don't pull another lucide |
| import this far down the file. ``Aa`` reads as captions |
| universally (YouTube convention). */} |
| <span |
| className={[ |
| "text-[12px] font-semibold leading-none tracking-tight", |
| enabled ? "" : "line-through opacity-60", |
| ].join(" ")} |
| > |
| Aa |
| </span> |
| </button> |
| </div> |
| </div> |
| ); |
| } |
|
|
|
|
| function ChoiceCard({ |
| choice, firing, onClick, |
| }: { |
| choice: CatalogItemView; |
| firing: boolean; |
| onClick: () => void; |
| }) { |
| // Future hook: the catalog row will eventually carry an |
| // ``asset_thumbnail_url`` for the destination scene. Until the |
| // backend populates that field, the gradient fallback below tints |
| // each card differently per intent so the user can at least |
| // distinguish paths visually. |
| const thumbUrl = (choice as { asset_thumbnail_url?: string }) |
| .asset_thumbnail_url || ""; |
| return ( |
| <button |
| type="button" |
| onClick={onClick} |
| disabled={firing} |
| aria-label={`Pick: ${choice.label}`} |
| aria-busy={firing} |
| className={[ |
| "group relative aspect-[4/3] rounded-2xl overflow-hidden", |
| "bg-gradient-to-br from-[#1f1f1f] to-[#0a0a0a]", |
| "border border-white/15", |
| // Stronger interaction feedback than the previous tiny |
| // 1.02 scale: hover lifts + glows, focus pops a ring, |
| // active press shrinks for tactile confirmation. |
| "transition-[transform,box-shadow,border-color] duration-200", |
| "hover:scale-[1.035] hover:border-[#ec4899]/70", |
| "hover:shadow-[0_18px_40px_-12px_rgba(236,72,153,0.45)]", |
| "active:scale-[0.97]", |
| "focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ec4899] focus-visible:ring-offset-2 focus-visible:ring-offset-black", |
| firing ? "opacity-90 cursor-wait" : "", |
| ].join(" ")} |
| > |
| {thumbUrl ? ( |
| <img |
| src={thumbUrl} |
| alt="" |
| aria-hidden |
| className={[ |
| "absolute inset-0 w-full h-full object-cover", |
| "opacity-90 group-hover:opacity-100 transition-opacity", |
| // Slight zoom on hover for cinematic feel. |
| "group-hover:scale-[1.03] transition-transform duration-300", |
| ].join(" ")} |
| /> |
| ) : ( |
| <div |
| className="absolute inset-0 opacity-80 group-hover:opacity-100 transition-opacity" |
| style={{ |
| // Placeholder tint based on intent code β derive a |
| // deterministic hue so authors can eyeball "this choice |
| // goes to path X" even before real thumbnails exist. |
| background: hueFromString(choice.intent_code || choice.label), |
| }} |
| aria-hidden |
| /> |
| )} |
| {/* |
| * Centered "play" affordance. Tells the user "this is a |
| * clickable destination, not a static card." Fades in on hover |
| * so it doesn't compete with the label at rest. |
| */} |
| <div |
| className={[ |
| "absolute inset-0 flex items-center justify-center", |
| "opacity-0 group-hover:opacity-100 transition-opacity duration-200", |
| "pointer-events-none", |
| ].join(" ")} |
| aria-hidden |
| > |
| <span className="w-12 h-12 rounded-full bg-white/15 border border-white/40 backdrop-blur-sm grid place-items-center"> |
| <Play className="w-5 h-5 text-white fill-current ml-0.5" aria-hidden /> |
| </span> |
| </div> |
| {/* |
| * Firing-state overlay: spinner on top of the card so the |
| * user has unmistakable feedback that their click landed and |
| * the next scene is being resolved. The card stays mostly |
| * visible (opacity-90 above) so they can still see what they |
| * picked while waiting. |
| */} |
| {firing && ( |
| <div |
| className="absolute inset-0 grid place-items-center bg-black/40 backdrop-blur-[1px]" |
| aria-hidden |
| > |
| <span className="inline-flex items-center gap-2 rounded-full bg-black/70 border border-white/20 px-3 py-1.5 text-xs text-white"> |
| <span className="w-3 h-3 border-2 border-white/40 border-t-white rounded-full animate-spin" /> |
| Loading⦠|
| </span> |
| </div> |
| )} |
| <div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 to-transparent"> |
| <div className="text-sm font-semibold text-white truncate"> |
| {choice.label} |
| </div> |
| {choice.intent_code && ( |
| <div className="text-[10px] text-white/60 truncate mt-0.5"> |
| {choice.intent_code.replace(/_/g, " ")} |
| </div> |
| )} |
| </div> |
| </button> |
| ); |
| } |
|
|
|
|
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββ |
|
|
| function fmtTime(seconds: number): string { |
| if (!isFinite(seconds) || seconds < 0) return "0:00"; |
| const total = Math.floor(seconds); |
| const m = Math.floor(total / 60); |
| const s = total % 60; |
| return `${m}:${s.toString().padStart(2, "0")}`; |
| } |
|
|
|
|
| function hueFromString(s: string): string { |
| // FNV-1a-ish hash β hue. Deterministic + cheap; no dependency. |
| let h = 0x811c9dc5; |
| for (let i = 0; i < s.length; i++) { |
| h ^= s.charCodeAt(i); |
| h = (h * 0x01000193) >>> 0; |
| } |
| const hue = h % 360; |
| return `linear-gradient(135deg, hsl(${hue}, 55%, 35%), hsl(${(hue + 40) % 360}, 45%, 20%))`; |
| } |
|
|