diff --git a/app/globals.css b/app/globals.css index f3ad3083..70a22cd6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1389,86 +1389,7 @@ p { } } -/* Audience Reaction Buttons */ -.reaction-btn { - width: 48px; - height: 48px; - border-radius: var(--radius-full); - display: flex; - align-items: center; - justify-content: center; - font-size: 24px; - cursor: pointer; - border: none; - position: relative; - overflow: hidden; - transition: transform var(--duration-fast) var(--easing-spring); -} - -.reaction-btn:active { - transform: scale(0.9); -} - -.reaction-btn-laugh { - background: radial-gradient(circle at 30% 30%, #FFD700, #FFA500); -} - -.reaction-btn-cheer { - background: radial-gradient(circle at 30% 30%, #FF69B4, #FF1493); -} - -.reaction-btn-gasp { - background: radial-gradient(circle at 30% 30%, #87CEEB, #4169E1); -} - -.reaction-btn-boo { - background: radial-gradient(circle at 30% 30%, #9370DB, #6A0DAD); -} - -.reaction-btn-applause { - background: radial-gradient(circle at 30% 30%, #50C878, #228B22); -} - -.reaction-btn::after { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - opacity: 0; - transition: opacity var(--duration-fast); -} - -.reaction-btn:hover::after { - background: rgba(255, 255, 255, 0.15); - opacity: 1; -} - -@keyframes ring-burst { - 0% { - box-shadow: 0 0 0 0 currentColor; - opacity: 0.6; - } - 100% { - box-shadow: 0 0 0 20px transparent; - opacity: 0; - } -} - -.reaction-btn-burst { - animation: ring-burst 0.4s var(--easing-out) forwards; -} - -.reaction-cooldown-sweep { - position: absolute; - inset: 0; - border-radius: inherit; - background: conic-gradient( - transparent var(--sweep-progress, 0%), - rgba(0, 0, 0, 0.4) var(--sweep-progress, 0%) - ); - pointer-events: none; -} - +/* Audience Reaction - Host Count Badges */ .reaction-count-badge { font-size: 12px; font-weight: 600; @@ -1486,14 +1407,6 @@ p { animation: count-bounce 0.3s var(--easing-spring); } -@media (min-width: 640px) { - .reaction-btn { - width: 56px; - height: 56px; - font-size: 28px; - } -} - /* Settings Panel */ .settings-panel { background: var(--color-surface-alt); @@ -4057,114 +3970,6 @@ textarea:focus-visible { } } -/* ========================================================================== - Audience Reaction Buttons - ========================================================================== */ - -.reaction-btn { - width: 48px; - height: 48px; - border-radius: var(--radius-full); - display: flex; - align-items: center; - justify-content: center; - font-size: 24px; - cursor: pointer; - border: none; - position: relative; - overflow: hidden; - transition: transform var(--duration-fast) var(--easing-spring); -} - -.reaction-btn:active { - transform: scale(0.9); -} - -.reaction-btn-laugh { - background: radial-gradient(circle at 30% 30%, #FFD700, #FFA500); -} - -.reaction-btn-cheer { - background: radial-gradient(circle at 30% 30%, #FF69B4, #FF1493); -} - -.reaction-btn-gasp { - background: radial-gradient(circle at 30% 30%, #87CEEB, #4169E1); -} - -.reaction-btn-boo { - background: radial-gradient(circle at 30% 30%, #9370DB, #6A0DAD); -} - -.reaction-btn-applause { - background: radial-gradient(circle at 30% 30%, #50C878, #228B22); -} - -.reaction-btn::after { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - opacity: 0; - transition: opacity var(--duration-fast); -} - -.reaction-btn:hover::after { - background: rgba(255, 255, 255, 0.15); - opacity: 1; -} - -@keyframes ring-burst { - 0% { - box-shadow: 0 0 0 0 currentColor; - opacity: 0.6; - } - 100% { - box-shadow: 0 0 0 20px transparent; - opacity: 0; - } -} - -.reaction-btn-burst { - animation: ring-burst 0.4s var(--easing-out) forwards; -} - -.reaction-cooldown-sweep { - position: absolute; - inset: 0; - border-radius: inherit; - background: conic-gradient( - transparent var(--sweep-progress, 0%), - rgba(0, 0, 0, 0.4) var(--sweep-progress, 0%) - ); - pointer-events: none; -} - -.reaction-count-badge { - font-size: 12px; - font-weight: 600; - color: rgba(255, 255, 255, 0.8); - min-width: 20px; - text-align: center; -} - -@keyframes count-bounce { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.3); } -} - -.reaction-count-bounce { - animation: count-bounce 0.3s var(--easing-spring); -} - -@media (min-width: 640px) { - .reaction-btn { - width: 56px; - height: 56px; - font-size: 28px; - } -} - /* ========================================================================== Movie Poster Frame ========================================================================== */ diff --git a/app/host/components/HostPerforming.tsx b/app/host/components/HostPerforming.tsx index 6df357a1..c5ae0b6c 100644 --- a/app/host/components/HostPerforming.tsx +++ b/app/host/components/HostPerforming.tsx @@ -25,6 +25,7 @@ export interface HostPerformingProps { chaosCooldown: boolean chaosCooldownRemaining: number chaosShaking: boolean + isSoloMode?: boolean teleprompterSettings: TeleprompterSettingsType teleprompterSettingsLoading: boolean onNextLine: () => void @@ -40,7 +41,7 @@ export interface HostPerformingProps { export function HostPerforming({ script, currentLineIndex, isPlaying, roomCode, networkLatency, spectatorMessages, scriptImageUrl, isGeneratingImage, - chaosCooldown, chaosCooldownRemaining, chaosShaking, + chaosCooldown, chaosCooldownRemaining, chaosShaking, isSoloMode = false, teleprompterSettings, teleprompterSettingsLoading, onNextLine, onPreviousLine, onTogglePlayPause, onTriggerChaos, onSetTeleprompterPreset, onSetTeleprompterCustom, onToggleTeleprompterAutoScroll, @@ -69,8 +70,8 @@ export function HostPerforming({ return ( - - + {!isSoloMode && } + {!isSoloMode && } {/* Poster */} @@ -172,7 +173,7 @@ export function HostPerforming({ {chaosCooldown ? ( 🌀 {Math.ceil(chaosCooldownRemaining)}s ) : ( - 🌀CHAOS + 🌀{isSoloMode ? 'TWIST' : 'CHAOS'} )} {chaosCooldown && (
diff --git a/app/host/page.tsx b/app/host/page.tsx index b728ec20..5b89a0ce 100644 --- a/app/host/page.tsx +++ b/app/host/page.tsx @@ -233,7 +233,7 @@ export default function HostPage() { setTimeout(() => setChaosShaking(false), 500) successHaptic() // native haptics on iOS; no-op on web if (navigator.vibrate) navigator.vibrate([100, 50, 100, 50, 200]) // Android web fallback - toast.info('CHAOS unleashed! Audience is voting on a plot twist...') + toast.info(settings.gameMode === 'SOLO' ? 'PLOT TWIST incoming!' : 'CHAOS unleashed! Audience is voting on a plot twist...') }, [socket, roomCode, chaosCooldown, toast, setChaosCooldown]) const handleSetupModeChange = (mode: 'quick' | 'custom') => { @@ -379,6 +379,7 @@ export default function HostPage() { chaosCooldown={chaosCooldown} chaosCooldownRemaining={chaosCooldownRemaining} chaosShaking={chaosShaking} + isSoloMode={settings.gameMode === 'SOLO'} teleprompterSettings={teleprompterSettings} teleprompterSettingsLoading={teleprompterSettingsLoading} onNextLine={nextLine} onPreviousLine={previousLine} diff --git a/components/AudienceReactionBar.tsx b/components/AudienceReactionBar.tsx index c988bc48..663b16f2 100644 --- a/components/AudienceReactionBar.tsx +++ b/components/AudienceReactionBar.tsx @@ -5,6 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { useSocket } from '@/contexts/SocketContext' import type { AudienceReactionType, AudienceReaction } from '@/lib/types' import { tapHaptic } from '@/hooks/useHaptics' +import { MOTION } from '@/lib/animations' interface AudienceReactionBarProps { roomCode: string @@ -28,10 +29,10 @@ const REACTION_LABELS: Record = { cheer: 'Cheer', gasp: 'Gasp', boo: 'Boo', - applause: 'Applause', + applause: 'Clap', cringe: 'Cringe', love: 'Love', - mindblown: 'Mind Blown' + mindblown: 'Wow' } const COOLDOWN_DURATION = 2000 @@ -39,95 +40,68 @@ const COOLDOWN_DURATION = 2000 export function AudienceReactionBar({ roomCode, isPerforming, isHost = false }: AudienceReactionBarProps) { const { socket } = useSocket() const [reactionCounts, setReactionCounts] = useState>({ - laugh: 0, - cheer: 0, - gasp: 0, - boo: 0, - applause: 0, - cringe: 0, - love: 0, - mindblown: 0 + laugh: 0, cheer: 0, gasp: 0, boo: 0, applause: 0, cringe: 0, love: 0, mindblown: 0 }) const [floatingReactions, setFloatingReactions] = useState([]) const [cooldown, setCooldown] = useState(false) const [cooldownProgress, setCooldownProgress] = useState(0) const [burstType, setBurstType] = useState(null) const [bouncingCounts, setBouncingCounts] = useState>(new Set()) + const [menuOpen, setMenuOpen] = useState(false) + const [lastReaction, setLastReaction] = useState(null) const prevCountsRef = useRef>({ - laugh: 0, - cheer: 0, - gasp: 0, - boo: 0, - applause: 0, - cringe: 0, - love: 0, - mindblown: 0 + laugh: 0, cheer: 0, gasp: 0, boo: 0, applause: 0, cringe: 0, love: 0, mindblown: 0 }) const cooldownStartRef = useRef(0) const rafRef = useRef(0) + const menuRef = useRef(null) - // Cooldown sweep animation via requestAnimationFrame const startCooldownSweep = useCallback(() => { cooldownStartRef.current = performance.now() setCooldownProgress(0) - const tick = () => { const elapsed = performance.now() - cooldownStartRef.current const progress = Math.min((elapsed / COOLDOWN_DURATION) * 100, 100) setCooldownProgress(progress) - if (progress < 100) { rafRef.current = requestAnimationFrame(tick) } else { setCooldownProgress(0) } } - rafRef.current = requestAnimationFrame(tick) }, []) - // Cleanup raf on unmount useEffect(() => { return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } }, []) - // Handle incoming reactions useEffect(() => { if (!socket) return - const handleReactionReceived = (reaction: AudienceReaction) => { - // Add to floating reactions setFloatingReactions(prev => [...prev.slice(-20), reaction]) - - // Remove after animation setTimeout(() => { setFloatingReactions(prev => prev.filter(r => r.id !== reaction.id)) }, 2000) } - const handleReactionCounts = (counts: Record) => { - // Detect which counts increased and trigger bounce const newBouncing = new Set() for (const type of Object.keys(counts) as AudienceReactionType[]) { if (counts[type] > prevCountsRef.current[type]) { newBouncing.add(type) } } - if (newBouncing.size > 0) { setBouncingCounts(newBouncing) setTimeout(() => setBouncingCounts(new Set()), 300) } - prevCountsRef.current = { ...counts } setReactionCounts(counts) } - socket.on('audience_reaction_received', handleReactionReceived) socket.on('audience_reaction_counts', handleReactionCounts) - return () => { socket.off('audience_reaction_received', handleReactionReceived) socket.off('audience_reaction_counts', handleReactionCounts) @@ -136,34 +110,44 @@ export function AudienceReactionBar({ roomCode, isPerforming, isHost = false }: const sendReaction = useCallback((type: AudienceReactionType) => { if (!socket || cooldown || !isPerforming) return - socket.emit('send_audience_reaction', roomCode, type) - - // Burst animation setBurstType(type) + setLastReaction(type) + setMenuOpen(false) setTimeout(() => setBurstType(null), 400) - - // Visual cooldown feedback setCooldown(true) startCooldownSweep() setTimeout(() => setCooldown(false), COOLDOWN_DURATION) - - // Haptic feedback tapHaptic() if (navigator.vibrate) navigator.vibrate(50) }, [socket, roomCode, cooldown, isPerforming, startCooldownSweep]) + // Close menu on tap outside + useEffect(() => { + if (!menuOpen) return + const handler = (e: PointerEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false) + } + } + document.addEventListener('pointerdown', handler) + return () => document.removeEventListener('pointerdown', handler) + }, [menuOpen]) + if (!isPerforming) return null - return ( -
- {/* Floating reactions overlay (for host view) */} - {isHost && ( + const reactionTypes = Object.keys(REACTION_EMOJIS) as AudienceReactionType[] + + // --- HOST VIEW (unchanged: floating emojis + compact count bar) --- + if (isHost) { + return ( +
+ {/* Floating reactions overlay */}
{floatingReactions.map(reaction => { - const size = 32 + Math.random() * 24 // 32–56px - const rotation = (Math.random() - 0.5) * 30 // -15 to 15 deg + const size = 32 + Math.random() * 24 + const rotation = (Math.random() - 0.5) * 30 return (
- )} - {/* Reaction counts (for host view) */} - {isHost && ( + {/* Reaction counts badge */}
- {(Object.keys(REACTION_EMOJIS) as AudienceReactionType[]).map(type => ( + {reactionTypes.map(type => (
{REACTION_EMOJIS[type]} @@ -194,33 +176,149 @@ export function AudienceReactionBar({ roomCode, isPerforming, isHost = false }:
))}
- )} - - {/* Reaction buttons (for audience) */} - {!isHost && ( -
-
- {(Object.keys(REACTION_EMOJIS) as AudienceReactionType[]).map(type => ( - - ))} -
-
- )} +
+ )} + + + )} + + + {/* FAB trigger button */} + setMenuOpen(prev => !prev)} + className="relative w-14 h-14 rounded-full flex items-center justify-center shadow-lg" + style={{ + background: menuOpen + ? 'rgba(245, 158, 66, 0.9)' + : 'rgba(30, 28, 25, 0.85)', + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + border: menuOpen + ? '2px solid rgba(245, 158, 66, 0.6)' + : '2px solid rgba(255, 255, 255, 0.12)', + boxShadow: menuOpen + ? '0 4px 20px rgba(245, 158, 66, 0.3)' + : '0 4px 16px rgba(0, 0, 0, 0.3)', + }} + whileTap={{ scale: 0.9 }} + animate={burstType ? { scale: [1, 1.2, 1] } : {}} + transition={MOTION.spring} + aria-label={menuOpen ? 'Close reactions' : 'Open reactions'} + > + + {lastReaction ? REACTION_EMOJIS[lastReaction] : '\u{26A1}'} + + + {/* Cooldown ring around FAB */} + {cooldown && ( + + + + )} +
) } diff --git a/server.ts b/server.ts index 2f7403de..0b817c7d 100644 --- a/server.ts +++ b/server.ts @@ -1004,7 +1004,7 @@ app.prepare().then(async () => { } })) - // Start a plot twist vote + // Start a plot twist vote (or auto-apply in solo mode) socket.on('start_plot_twist', withErrorHandler(socket, 'start_plot_twist', (roomCode) => { const room = roomService.getRoomFromCache(roomCode) if (!room || !room.audienceInteraction) return @@ -1023,6 +1023,59 @@ app.prepare().then(async () => { // Emit dramatic sound effect when twist starts io.to(roomCode).emit('play_sound_effect', 'plot_twist_trigger' as SoundEffectType) + // --- SOLO MODE: skip voting, pick a random twist and inject immediately --- + if (room.gameMode === 'SOLO') { + const winningTwist = finalizePlotTwist(room.audienceInteraction) + if (winningTwist) { + io.to(roomCode).emit('play_sound_effect', 'plot_twist_reveal' as SoundEffectType) + io.to(roomCode).emit('plot_twist_result', winningTwist) + + const speakers = room.script?.lines.map(l => l.speaker).filter((v, i, a) => a.indexOf(v) === i) || [] + const setting = room.setting || '' + const recentDialogue = room.script?.lines.slice( + Math.max(0, room.currentLineIndex - 5), + room.currentLineIndex + 1 + ) || [] + + // Generate AI injection in background (don't block) + generateAITwistInjection( + winningTwist, + speakers, + { + setting, + characters: speakers, + recentDialogue, + scriptPosition: room.currentLineIndex / (room.script?.lines.length || 1) < 0.33 ? 'early' : + room.currentLineIndex / (room.script?.lines.length || 1) < 0.66 ? 'mid' : 'late', + comedyStyle: room.scriptCustomization?.comedyStyle, + isMature: room.isMature + } + ).then(injectedLines => { + const latestRoom = roomService.getRoomFromCache(roomCode) + if (latestRoom?.script && latestRoom.gameState === 'PERFORMING' && injectedLines.length > 0) { + const insertIndex = Math.min(latestRoom.currentLineIndex + 1, latestRoom.script.lines.length) + latestRoom.script.lines.splice(insertIndex, 0, ...injectedLines) + io.to(roomCode).emit('plot_twist_injected', insertIndex, injectedLines) + roomService.updateRoom(latestRoom) + + regenerateTwistsForRoom( + roomCode, + latestRoom.script as Script, + latestRoom.currentLineIndex, + setting, + latestRoom.isMature, + latestRoom.scriptCustomization?.comedyStyle + ) + } + }).catch(error => { + logger.error(`Solo plot twist injection failed for room ${roomCode}:`, error) + io.to(roomCode).emit('error', 'Plot twist failed — the show goes on!') + }) + } + return + } + + // --- MULTIPLAYER: broadcast vote to audience, finalize after timeout --- io.to(roomCode).emit('plot_twist_started', twist) // Set timeout to finalize and inject