From 8bdc004c25e909e18f56de7bddd945bbebd5a36e Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Sun, 30 Aug 2026 22:31:05 +0000 Subject: [PATCH 1/2] feat: make FableLoom playback cinematic --- .../components/fableloom/LoomPlayPanel.jsx | 214 ++++++++++++------ .../fableloom/LoomPlayPanel.test.jsx | 28 ++- client/src/pages/FableLoomStory.jsx | 19 +- client/src/pages/FableLoomStory.test.jsx | 14 ++ 4 files changed, 198 insertions(+), 77 deletions(-) diff --git a/client/src/components/fableloom/LoomPlayPanel.jsx b/client/src/components/fableloom/LoomPlayPanel.jsx index 7f716b6e1..54c9a52db 100644 --- a/client/src/components/fableloom/LoomPlayPanel.jsx +++ b/client/src/components/fableloom/LoomPlayPanel.jsx @@ -15,7 +15,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { io } from 'socket.io-client'; -import { Loader2, RotateCcw, Send, Flag, Volume2, Mic, CheckCircle2, AlertCircle, QrCode, Smartphone } from 'lucide-react'; +import { Loader2, RotateCcw, Send, Flag, Volume2, Mic, CheckCircle2, AlertCircle, QrCode, Smartphone, X } from 'lucide-react'; import MediaImage from '../MediaImage'; import { useAsyncAction } from '../../hooks/useAsyncAction'; import { playLoomTurn } from '../../services/api'; @@ -60,7 +60,18 @@ const initialPhaseForNode = (node) => { return 'hold'; }; -export default function LoomPlayPanel({ loom, episode: initialEpisode }) { +const episodePreviewMode = (episode) => { + const opening = findNode(episode, episode?.startNodeId); + if ( + opening?.videoHistoryId + || opening?.playbackAssets?.entryVideoHistoryId + || opening?.playbackAssets?.holdLoopVideoHistoryIds?.length + ) return 'video'; + if (opening?.image) return 'image'; + return 'text'; +}; + +export default function LoomPlayPanel({ loom, episode: initialEpisode, onClose }) { const [playEpisodeId, setPlayEpisodeId] = useState(initialEpisode.id); const episode = loom.episodes?.find((item) => item.id === playEpisodeId) || initialEpisode; const episodeIndex = loom.episodes?.findIndex((item) => item.id === episode.id) ?? -1; @@ -91,7 +102,7 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { const [pendingTransition, setPendingTransition] = useState(null); const [transcript, setTranscript] = useState(() => (start ? [{ role: 'scene', node: start }] : [])); const [message, setMessage] = useState(''); - const [previewMode, setPreviewMode] = useState('text'); + const [previewMode, setPreviewMode] = useState(() => episodePreviewMode(initialEpisode)); const [failedVideoId, setFailedVideoId] = useState(null); const [showInspector, setShowInspector] = useState(false); const [hostedModalOpen, setHostedModalOpen] = useState(false); @@ -192,6 +203,8 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { useEffect(() => { setPlayEpisodeId(initialEpisode.id); }, [initialEpisode.id]); + useEffect(() => { setPreviewMode(episodePreviewMode(episode)); }, [episode.id]); + // An episode switch (or a changed opening scene) re-anchors the session. useEffect(() => { restart(); }, [start]); @@ -294,9 +307,23 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { if (!start) { return ( -

- This episode has no opening scene yet — weave or add scenes first. -

+
+ {onClose && ( +
+ +
+ )} +

+ This episode has no opening scene yet — weave or add scenes first. +

+
); } @@ -305,7 +332,7 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { ); return ( -
+