Xenova's picture
Xenova HF Staff
Upload application
59c3ada verified
import { useReducer, useState, useRef, useEffect, useCallback } from "react";
import type React from "react";
import { Dices, RefreshCw } from "lucide-react";
import type { generateFn } from "../hooks/useLLM";
import NeoButton from "./NeoButton";
import OptionCard from "./OptionCard";
import { STORY_DATA } from "../constants";
interface MainApplicationProps {
isVisible: boolean;
playPopSound: () => void;
playHoverSound: () => void;
generate: generateFn;
streamTTS: (onAudioChunk: (chunk: { audio: Float32Array; text?: string }) => void) => {
splitter: any;
ttsPromise: Promise<void>;
};
isTTSReady: boolean;
audioWorkletNode: AudioWorkletNode | null;
toggleMusic: (force?: boolean) => void;
isMusicPlaying: boolean;
}
type StoryState = {
character: string;
setting: string;
item: string;
theme: string;
length: string;
customCharacter: string;
customSetting: string;
customItem: string;
};
type StoryAction =
| { type: "SET_FIELD"; field: keyof StoryState; value: string }
| {
type: "SURPRISE_ME";
payload: Omit<StoryState, "customCharacter" | "customSetting" | "customItem">;
}
| { type: "RESET" };
const initialState: StoryState = {
character: STORY_DATA.characters[0],
setting: STORY_DATA.settings[0],
item: STORY_DATA.items[0],
theme: STORY_DATA.themes[0],
length: STORY_DATA.length[0],
customCharacter: "",
customSetting: "",
customItem: "",
};
const storyReducer = (state: StoryState, action: StoryAction): StoryState => {
switch (action.type) {
case "SET_FIELD":
return { ...state, [action.field]: action.value };
case "SURPRISE_ME":
return { ...initialState, ...action.payload };
case "RESET":
return initialState;
default:
return state;
}
};
const MainApplication: React.FC<MainApplicationProps> = ({
isVisible,
playPopSound,
playHoverSound,
generate,
streamTTS,
isTTSReady,
audioWorkletNode,
toggleMusic,
isMusicPlaying,
}) => {
const [storyState, dispatch] = useReducer(storyReducer, initialState);
const { character, setting, item, theme, length, customCharacter, customSetting, customItem } = storyState;
const [generatedStory, setGeneratedStory] = useState<string | null>(null);
const [isShuffling, setIsShuffling] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isSpeaking, setIsSpeaking] = useState<boolean>(false);
const [previewText, setPreviewText] = useState<string>("");
const storyPanelRef = useRef<HTMLDivElement | null>(null);
const [ranges, setRanges] = useState<Array<{ start: number; end: number }>>([]);
const rangesRef = useRef(ranges);
useEffect(() => {
rangesRef.current = ranges;
}, [ranges]);
const pendingRef = useRef<string[]>([]);
const rawSearchStartRef = useRef(0);
const normDataRef = useRef<{ norm: string; map: number[] } | null>(null);
const [currentChunkIdx, setCurrentChunkIdx] = useState<number>(-1);
const finishedIndexRef = useRef<number>(-1);
const textContainerRef = useRef<HTMLDivElement | null>(null);
const chunkRefs = useRef<(HTMLSpanElement | null)[]>([]);
const resumeMusicOnPlaybackEndRef = useRef<boolean>(false);
const activeSplitterRef = useRef<any>(null);
const displayedText = previewText || generatedStory || "";
const buildNorm = (text: string) => {
const map: number[] = [];
let norm = "";
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (/\s/.test(ch)) continue;
map.push(i);
norm += ch;
}
return { norm, map };
};
const tryResolve = useCallback(() => {
const normData = normDataRef.current;
if (!normData) return;
const { norm, map } = normData;
const nextRanges = rangesRef.current.slice();
const normStartFromRaw = () => {
let i = 0;
while (i < map.length && map[i] < rawSearchStartRef.current) i++;
return i;
};
let changed = false;
while (pendingRef.current.length) {
const nextText = pendingRef.current[0];
const needle = nextText.replace(/\s+/g, "");
if (!needle) {
pendingRef.current.shift();
continue;
}
const startNormIdx = normStartFromRaw();
const idx = norm.indexOf(needle, startNormIdx);
if (idx === -1) break;
const startRaw = map[idx];
const endRaw = map[idx + needle.length - 1] + 1;
nextRanges.push({ start: startRaw, end: endRaw });
rawSearchStartRef.current = endRaw;
pendingRef.current.shift();
changed = true;
}
if (changed) {
setRanges(nextRanges);
setCurrentChunkIdx((prev) =>
prev === -1 ? Math.min(finishedIndexRef.current + 1, nextRanges.length - 1) : prev,
);
}
}, []);
useEffect(() => {
normDataRef.current = buildNorm(displayedText);
tryResolve();
}, [displayedText, tryResolve]);
useEffect(() => {
if (!audioWorkletNode) return;
const handler = (event: MessageEvent) => {
const data = (event as any).data;
if (!data || typeof data !== "object") return;
if (data.type === "next_chunk") {
finishedIndexRef.current += 1;
const nextIdx = finishedIndexRef.current + 1;
setCurrentChunkIdx(nextIdx < rangesRef.current.length ? nextIdx : -1);
} else if (data.type === "playback_ended") {
setIsSpeaking(false);
setCurrentChunkIdx(-1);
if (resumeMusicOnPlaybackEndRef.current) {
toggleMusic();
}
}
};
(audioWorkletNode.port as any).onmessage = handler;
return () => {
if (audioWorkletNode?.port) (audioWorkletNode.port as any).onmessage = null;
};
}, [audioWorkletNode, toggleMusic]);
useEffect(() => {
if (currentChunkIdx < 0) return;
const container = textContainerRef.current;
const el = chunkRefs.current[currentChunkIdx];
if (!container || !el) return;
const containerRect = container.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const elTopInContainer = elRect.top - containerRect.top + container.scrollTop;
const targetTop = Math.max(0, elTopInContainer - container.clientHeight * 0.3);
container.scrollTo({ top: targetTop, behavior: "smooth" });
}, [currentChunkIdx]);
const getRandomItem = (arr: string[]): string => arr[Math.floor(Math.random() * arr.length)];
const handleSurpriseMe = () => {
playPopSound();
if (isShuffling) return;
setIsShuffling(true);
dispatch({ type: "SET_FIELD", field: "customCharacter", value: "" });
dispatch({ type: "SET_FIELD", field: "customSetting", value: "" });
dispatch({ type: "SET_FIELD", field: "customItem", value: "" });
let count = 0;
const max = 15;
const interval = setInterval(() => {
dispatch({
type: "SURPRISE_ME",
payload: {
character: getRandomItem(STORY_DATA.characters),
setting: getRandomItem(STORY_DATA.settings),
item: getRandomItem(STORY_DATA.items),
theme: getRandomItem(STORY_DATA.themes),
length: getRandomItem(STORY_DATA.length),
},
});
count++;
if (count >= max) {
clearInterval(interval);
setIsShuffling(false);
}
}, 60);
};
const generateStory = async () => {
if (!audioWorkletNode) return;
playPopSound();
setIsLoading(true);
setPreviewText("");
setGeneratedStory(null);
setRanges([]);
rangesRef.current = [];
pendingRef.current = [];
rawSearchStartRef.current = 0;
setCurrentChunkIdx(-1);
finishedIndexRef.current = -1;
chunkRefs.current = [];
const wasMusicPlaying = isMusicPlaying;
resumeMusicOnPlaybackEndRef.current = wasMusicPlaying;
storyPanelRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
setIsSpeaking(true);
if (wasMusicPlaying) toggleMusic();
const { splitter, ttsPromise } = streamTTS(({ audio, text }) => {
audioWorkletNode.port.postMessage(audio);
if (text) {
pendingRef.current.push(text);
tryResolve();
}
});
activeSplitterRef.current = splitter;
const selectedCharacter = (customCharacter || character).trim();
const selectedSetting = (customSetting || setting).trim();
const selectedItem = (customItem || item).trim();
const lengthMap: Record<string, string> = {
Short: "100-200 word",
Medium: "200-300 word",
Long: "300-400 word",
};
const userMessage = `Write a ${lengthMap[length]} ${theme.toLowerCase()} story about ${selectedCharacter} ${selectedSetting} that ${selectedItem}.`;
try {
const llmPromise = generate(
[{ role: "user", content: userMessage }],
(token: string) => setPreviewText((prev) => prev + token),
splitter,
);
await Promise.all([llmPromise, ttsPromise]);
} catch {
setGeneratedStory(previewText || "Sorry, the story failed to generate.");
} finally {
activeSplitterRef.current = null;
setIsLoading(false);
}
};
const handleReset = () => {
playPopSound();
activeSplitterRef.current?.close?.();
activeSplitterRef.current = null;
audioWorkletNode?.port.postMessage("stop");
setIsSpeaking(false);
if (!isMusicPlaying && resumeMusicOnPlaybackEndRef.current) {
toggleMusic();
}
resumeMusicOnPlaybackEndRef.current = false;
setRanges([]);
rangesRef.current = [];
pendingRef.current = [];
rawSearchStartRef.current = 0;
setCurrentChunkIdx(-1);
finishedIndexRef.current = -1;
chunkRefs.current = [];
setGeneratedStory(null);
setPreviewText("");
dispatch({ type: "RESET" });
};
const transitionClass = isVisible ? "opacity-100" : "opacity-0 translate-y-10 pointer-events-none";
return (
<div
className={`h-screen py-16 px-8 overflow-y-scroll transition-all duration-700 ease-in-out ${isVisible ? "delay-300" : ""} ${transitionClass}`}
>
<header className="text-center mb-8">
<h1 className="text-5xl md:text-7xl font-black tracking-tighter">Let's Make a Story!</h1>
<p className="text-lg mt-2 bg-cyan-300 inline-block px-3 py-1 border-2 border-black rounded-md">
Runs completely on your device, powered by
<a
href="https://huggingface.co/onnx-community/gemma-3-270m-it-ONNX"
target="_blank"
rel="noopener noreferrer"
className="ml-1 font-semibold text-gray-700 hover:text-black transition-colors"
>
Gemma 3 270M
</a>
!
</p>
</header>
<main className="w-full max-w-4xl mx-auto space-y-6">
<OptionCard
title="1. Choose a Character"
options={STORY_DATA.characters}
selected={character}
onSelect={(val) => dispatch({ type: "SET_FIELD", field: "character", value: val })}
customValue={customCharacter}
onCustomChange={(val) =>
dispatch({
type: "SET_FIELD",
field: "customCharacter",
value: val,
})
}
isShuffling={isShuffling}
isVisible={isVisible}
playPopSound={playPopSound}
playHoverSound={playHoverSound}
/>
<OptionCard
title="2. Pick a Setting"
options={STORY_DATA.settings}
selected={setting}
onSelect={(val) => dispatch({ type: "SET_FIELD", field: "setting", value: val })}
customValue={customSetting}
onCustomChange={(val) => dispatch({ type: "SET_FIELD", field: "customSetting", value: val })}
isShuffling={isShuffling}
isVisible={isVisible}
playPopSound={playPopSound}
playHoverSound={playHoverSound}
/>
<OptionCard
title="3. Add a Twist"
options={STORY_DATA.items}
selected={item}
onSelect={(val) => dispatch({ type: "SET_FIELD", field: "item", value: val })}
customValue={customItem}
onCustomChange={(val) => dispatch({ type: "SET_FIELD", field: "customItem", value: val })}
isShuffling={isShuffling}
isVisible={isVisible}
playPopSound={playPopSound}
playHoverSound={playHoverSound}
/>
<div
className={`bg-white border-2 border-black rounded-xl p-6 shadow-[6px_6px_0px_rgba(0,0,0,0.1)] transition-opacity duration-500 ${isShuffling ? "animate-shake" : ""} ${isVisible ? "animate-slide-in" : "opacity-0"}`}
>
<h3 className="text-2xl font-bold mb-4">4. Select a Theme</h3>
<div className="flex flex-wrap gap-3">
{STORY_DATA.themes.map((t) => (
<button
key={t}
onClick={() => {
playPopSound();
dispatch({ type: "SET_FIELD", field: "theme", value: t });
}}
onMouseEnter={playHoverSound}
className={`font-bold py-3 px-6 border-2 rounded-lg transition-all duration-150 text-lg transform hover:-translate-y-0.5 active:translate-y-0 ${theme === t ? "bg-pink-400 border-black shadow-[2px_2px_0px_#000]" : "bg-gray-100 border-gray-300 hover:bg-gray-200"}`}
>
{t}
</button>
))}
</div>
</div>
<div
className={`bg-white border-2 border-black rounded-xl p-6 shadow-[6px_6px_0px_rgba(0,0,0,0.1)] transition-opacity duration-500 ${isShuffling ? "animate-shake" : ""} ${isVisible ? "animate-slide-in" : "opacity-0"}`}
>
<h3 className="text-2xl font-bold mb-4">5. Story Length</h3>
<div className="flex flex-wrap gap-3">
{STORY_DATA.length.map((l) => (
<button
key={l}
onClick={() => {
playPopSound();
dispatch({ type: "SET_FIELD", field: "length", value: l });
}}
onMouseEnter={playHoverSound}
className={`font-bold py-3 px-6 border-2 rounded-lg transition-all duration-150 text-lg transform hover:-translate-y-0.5 active:translate-y-0 ${length === l ? "bg-pink-400 border-black shadow-[2px_2px_0px_#000]" : "bg-gray-100 border-gray-300 hover:bg-gray-200"}`}
>
{l}
</button>
))}
</div>
</div>
<div className="pt-6 flex flex-col sm:flex-row items-center justify-center gap-4">
<NeoButton
onClick={generateStory}
{...{
className: "w-full sm:w-auto text-xl",
isLoading,
disabled: isShuffling || isLoading,
playHoverSound,
}}
>
Make My Story
</NeoButton>
<NeoButton
onClick={handleSurpriseMe}
{...{
variant: "accent",
className: "w-full sm:w-auto text-xl",
disabled: isShuffling || isLoading,
playHoverSound,
}}
>
<Dices size={24} />
Surprise Me!
</NeoButton>
<NeoButton
onClick={handleReset}
{...{
variant: "secondary",
className: "w-full sm:w-auto text-xl",
disabled: isLoading,
playHoverSound,
}}
>
<RefreshCw size={20} />
Reset
</NeoButton>
</div>
{/* Story Panel (covers screen height, no separate story page) */}
<div
ref={storyPanelRef}
className="mt-8 bg-white border-2 border-black rounded-xl p-8 shadow-[8px_8px_0px_#000] overflow-y-auto animate-slide-in"
>
<h3 className="text-3xl font-black mb-6 text-center">Your Story</h3>
<div
ref={textContainerRef}
className="text-2xl leading-relaxed font-serif whitespace-pre-wrap h-[400px] overflow-y-auto"
>
{(() => {
const displayed = displayedText;
if (!displayed) {
return "Click “Make My Story” to generate a bedtime story.";
}
const pieces: React.ReactNode[] = [];
let last = 0;
for (let i = 0; i < ranges.length; i++) {
const { start, end } = ranges[i];
if (start > last) {
pieces.push(<span key={`n-${i}`}>{displayed.slice(last, start)}</span>);
}
pieces.push(
<span
key={`h-${i}`}
ref={(el) => {
chunkRefs.current[i] = el;
}}
className={i === currentChunkIdx ? "bg-yellow-200" : ""}
>
{displayed.slice(start, end)}
</span>,
);
last = end;
}
if (last < displayed.length) {
pieces.push(<span key="tail">{displayed.slice(last)}</span>);
}
return pieces;
})()}
</div>
{isLoading && (
<div className="mt-4 text-center text-sm opacity-70">
{isSpeaking ? "Generating and speaking..." : "Generating..."}
</div>
)}
{generatedStory && !isLoading && isTTSReady && !isSpeaking && (
<div className="mt-4 flex justify-center">
<p className="text-sm opacity-70">Story finished.</p>
</div>
)}
</div>
</main>
</div>
);
};
export default MainApplication;