HomePilot / frontend /src /ui /interactive /StandardPlayer.tsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
36.1 kB
/**
* StandardPlayer β€” YouTube-style surface for `interaction_type =
* "standard_project"` experiences.
*
* Layout (matching the user's reference screenshots):
*
* β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
* β”‚ ⟲ <video playback> β†’ β”‚ ← top row
* β”‚ β”‚
* β”‚ ⟲10 β–Ά/⏸ 10⟳ β”‚ ← center
* β”‚ β”‚
* β”‚ ─────[seek bar]────────────────── [thumbs] β”‚
* β”‚ β–Ά πŸ”Š 00:23 / 01:30 β›Ά β”‚ ← bottom
* β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
*
* When the current scene finishes (or the user taps Next), a
* centered "What to do next?" modal appears with image-card choices
* sourced from the action catalog. Tapping one calls resolveTurn
* β€” same endpoint the persona Live Action sheet uses, so no
* backend changes.
*
* Everything is additive: ``InteractivePlayer`` routes to this
* component only when ``resolveInteractionType(exp)`` returns
* "standard_project". Persona mode is unaffected.
*/
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;
/** Experience id (a.k.a. project id). Threaded in so the player
* can preload upcoming scene assets via ``useScenePreload`` β€”
* optional only because some legacy callers may not have it. */
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);
// ``mediaError`` covers BOTH <img> and <video> failures. Was
// ``imageError`` for images only β€” the <video> element used to fall
// through to a silent black stage, which was indistinguishable from
// a still-loading clip and produced the "Play screen is empty"
// reports we kept getting. Renamed + a forced retry-counter that
// appends a cache-bust ?t=N to the src so the browser actually
// re-fetches when the user clicks Retry.
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 "";
// Trust the URL whenever it's present. The previous gate
// (status === "ready") was redundant β€” _build_initial_scene
// only stamps asset_url after asset registry resolution
// succeeds β€” and it caused a black stage for any backend that
// omitted the explicit "ready" stamp (legacy sessions, the
// pending-then-resolve race, single-tap retries, etc).
return scene.asset_url;
})();
const url = retryNonce > 0 && baseUrl
? `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}_t=${retryNonce}`
: baseUrl;
// ── Preload + transitions (cinematic engine spec Β§5–7) ─────────
// Walk the experience graph from the current scene and preload
// every reachable next-scene's image so navigation feels instant
// β€” the spec called out "Avoid lag when switching scenes" as
// critical for premium UX. depth=2 covers the immediate Continue
// hop plus the first step of every choice branch.
//
// ``scene.id`` is shaped ``initial_<node_id>`` for the entry
// payload and ``<node_id>`` for subsequent turns; strip the
// prefix so the graph lookup matches the actual node row.
const currentNodeId = String(scene?.id || "")
.replace(/^initial_/, "")
.trim();
useScenePreload(api, experienceId || "", currentNodeId);
// Cross-fade the media wrapper on scene change. The fade key
// changes when EITHER the scene id OR the asset url changes,
// so a regenerate-scene also fades the new image in.
const fade = useFadeOnSceneChange(fadeKey(scene?.id, url));
const mediaHint = String(scene?.media_kind || "").toLowerCase();
// Media-kind detection is the union of three signals β€” any one is
// enough. The boundary character set covers ``.png?…``, ``.png&…``
// (ComfyUI /view URLs), ``.png#…``, and end-of-string. The previous
// ``(\?|$)`` form matched only the first and last, so a URL like
// http://comfy:8188/view?filename=foo.png&subfolder=&type=output
// was misclassified as "unknown" and the Standard player rendered
// a black stage with the "Scene not available yet." placeholder.
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();
// Voice gate precedence (industry best-practice):
// 1) Global Voice Assistant default (Enterprise Settings)
// 2) Optional per-experience override in audience_profile
// 3) Runtime mute button (single control for media + narration)
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;
// Load catalog as the pool of decision cards. For standard
// projects it's the author's branch choices (mapped onto the
// shared catalog table by the backend); for a graph without
// decisions it will be empty and the modal just won't open.
const catalog = useAsyncResource<CatalogItemView[]>(
(signal) => api.getCatalog(sessionId, signal),
[api, sessionId],
);
// Top-2 unlocked options for the modal β€” a standard branching
// video usually only has two-to-four choices per decision.
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]);
// ── Video element wiring ─────────────────────────────────────
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]);
// Timed still-image playback: treat images like fixed-duration
// scenes. When the timer completes, open the decision modal.
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]);
// Keep the <video> element's mute state in sync with the control.
useEffect(() => {
if (videoRef.current) videoRef.current.muted = muted;
}, [muted]);
// Scene narration voice: additive and non-destructive.
// Speaks once per scene/text pair when voice is enabled.
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]);
// Track fullscreen state from the browser so our icon stays honest.
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
// ── Controls ────────────────────────────────────────────────
const togglePlay = useCallback(() => {
if (isImage) {
setPlaying((p) => !p);
return;
}
const v = videoRef.current;
if (!v) return;
if (v.paused) v.play().catch(() => { /* autoplay policy */ });
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(() => { /* autoplay policy */ });
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%))`;
}