diff --git a/public/sound/button_down.m4a b/public/sound/button_down.m4a new file mode 100644 index 000000000000..47ce4559d6e2 Binary files /dev/null and b/public/sound/button_down.m4a differ diff --git a/public/sound/button_up.m4a b/public/sound/button_up.m4a new file mode 100644 index 000000000000..8ea8607a4ad3 Binary files /dev/null and b/public/sound/button_up.m4a differ diff --git a/public/sound/paper_rubbing.m4a b/public/sound/paper_rubbing.m4a new file mode 100644 index 000000000000..f262273d29e2 Binary files /dev/null and b/public/sound/paper_rubbing.m4a differ diff --git a/public/sound/pop.m4a b/public/sound/pop.m4a new file mode 100644 index 000000000000..164a405a1c16 Binary files /dev/null and b/public/sound/pop.m4a differ diff --git a/public/sound/sharp_click.m4a b/public/sound/sharp_click.m4a new file mode 100644 index 000000000000..cf336af073cc Binary files /dev/null and b/public/sound/sharp_click.m4a differ diff --git a/public/sound/slide.m4a b/public/sound/slide.m4a new file mode 100644 index 000000000000..6e7806b3a74b Binary files /dev/null and b/public/sound/slide.m4a differ diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 95afe8293650..6b1f531f8013 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -10,6 +10,8 @@ import { } from '../ui/tooltip'; import HeaderUser from './HeaderUser'; import { useLayoutContext } from '@/contexts/layout'; +import { useAudioEffects } from '@/hooks/useAudioEffects'; +import { Volume2Icon, VolumeOffIcon } from 'lucide-react'; interface HeaderProps { toggleTheme: () => void; @@ -19,7 +21,19 @@ interface HeaderProps { const Header: React.FC = () => { const { isMacOS, readingMode, toggleReadingMode, toggleIsOpenSidebar } = useLayoutContext(); + const { isSoundEnabled, toggleSound, playButtonUp, playButtonDown } = + useAudioEffects(); const modifier = isMacOS ? '⌘' : 'Ctrl'; + + const handleSoundToggle = () => { + if (isSoundEnabled) { + playButtonDown(); // Play before disabling + setTimeout(() => toggleSound(), 100); + } else { + toggleSound(); + setTimeout(() => playButtonUp(), 100); // Play after enabling + } + }; return (
@@ -63,6 +77,23 @@ const Header: React.FC = () => {
{/* Command palette */} + + {/* Sound toggle */} + + {/* Reading mode toggle */} diff --git a/src/components/layout/TableOfContents.tsx b/src/components/layout/TableOfContents.tsx index eae5a9c05b5a..b68d12efc873 100644 --- a/src/components/layout/TableOfContents.tsx +++ b/src/components/layout/TableOfContents.tsx @@ -5,6 +5,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import HeadingNavigator from './HeadingNavigator'; import { flattenTocItems } from '@/lib/utils'; +import { useAudioEffects } from '@/hooks/useAudioEffects'; interface TableOfContentsProps { items?: ITocItem[]; @@ -21,6 +22,7 @@ const TableOfContents: React.FC = ({ items }) => { const [activeId, setActiveId] = useState(''); const shouldBlockHeadingObserver = useRef(false); const timeoutRef = useRef(null); // Still needed for blocking observer + const { playSharpClick, isSoundEnabled } = useAudioEffects(); const scrollToId = useCallback((id: string) => { const element = document.getElementById(id); @@ -53,13 +55,17 @@ const TableOfContents: React.FC = ({ items }) => { width: `${getIndicatorWidth(item.depth)}px`, borderRadius: '2px', }} - className={cn('bg-border mr-2.5 flex h-0.5 text-transparent', { - 'bg-border-dark dark:bg-border-light': item.id === activeId, - })} + className={cn( + 'bg-border hover:bg-border-dark/70 dark:hover:bg-border-light/70 mr-2.5 flex h-0.5 text-transparent transition-all', + { + 'bg-border-dark dark:bg-border-light': item.id === activeId, + }, + )} onClick={e => { e.preventDefault(); scrollToId(item.id); }} + onMouseEnter={() => isSoundEnabled && playSharpClick()} > {item.value} @@ -80,7 +86,7 @@ const TableOfContents: React.FC = ({ items }) => { style={{ marginLeft: `${depth * 16}px` }} href={`#${item.id}`} className={cn( - 'flex cursor-pointer items-center gap-1 rounded px-2 py-1.25 text-left text-xs leading-normal font-medium', + 'flex cursor-pointer items-center gap-1 rounded px-2 py-1.25 text-left text-xs leading-normal font-medium transition-all', getHeadingLevelClass(item.depth), 'hover:bg-background-secondary-light hover:dark:bg-background-secondary', { @@ -93,6 +99,7 @@ const TableOfContents: React.FC = ({ items }) => { e.preventDefault(); scrollToId(item.id); }} + onMouseEnter={() => isSoundEnabled && playSharpClick()} > {item.value} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index d29f86ebdfdd..5d36b2b94e6b 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -3,6 +3,7 @@ import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; +import { useAudioEffects } from '@/hooks/useAudioEffects'; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap font-sans rounded-lg text-sm leading-6 font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer", @@ -35,22 +36,80 @@ const buttonVariants = cva( }, ); +interface ButtonProps + extends React.ComponentProps<'button'>, + VariantProps { + asChild?: boolean; + enableSounds?: boolean; + soundOnHover?: 'slide' | 'none'; + soundOnClick?: 'pop' | 'sharp-click' | 'button-toggle' | 'none'; + isToggled?: boolean; +} + function Button({ className, variant, size, asChild = false, + enableSounds = true, + soundOnHover = 'none', + soundOnClick = 'pop', + isToggled, + onClick, + onMouseEnter, ...props -}: React.ComponentProps<'button'> & - VariantProps & { - asChild?: boolean; - }) { +}: ButtonProps) { const Comp = asChild ? Slot : 'button'; + const { + playSlide, + playPop, + playSharpClick, + playButtonUp, + playButtonDown, + isSoundEnabled, + } = useAudioEffects(); + + const handleClick = (e: React.MouseEvent) => { + if (enableSounds && isSoundEnabled) { + if (soundOnClick === 'button-toggle') { + // For toggle buttons, play different sounds based on state + if (isToggled !== undefined) { + if (isToggled) { + playButtonDown(); + } else { + playButtonUp(); + } + } else { + playPop(); + } + } else if (soundOnClick === 'pop') { + playPop(); + } else if (soundOnClick === 'sharp-click') { + playSharpClick(); + } + } + + if (onClick) { + onClick(e); + } + }; + + const handleMouseEnter = (e: React.MouseEvent) => { + if (enableSounds && isSoundEnabled && soundOnHover === 'slide') { + playSlide(); + } + + if (onMouseEnter) { + onMouseEnter(e); + } + }; return ( ); diff --git a/src/contexts/SoundProvider.tsx b/src/contexts/SoundProvider.tsx new file mode 100644 index 000000000000..0d6f37b2cd49 --- /dev/null +++ b/src/contexts/SoundProvider.tsx @@ -0,0 +1,186 @@ +'use client'; + +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, +} from 'react'; + +interface SoundContextType { + isSoundEnabled: boolean; + setIsSoundEnabled: (enabled: boolean) => void; + playPaperRubbing: () => void; + playSharpClick: () => void; + playSlide: () => void; + playPop: () => void; + playButtonUp: () => void; + playButtonDown: () => void; + toggleSound: () => void; +} + +const SoundContext = createContext(undefined); + +interface SoundProviderProps { + children: React.ReactNode; +} + +export const SoundProvider: React.FC = ({ children }) => { + const [isSoundEnabled, setIsSoundEnabled] = useState(true); + const [audioPool, setAudioPool] = useState< + Record + >({}); + const [isInitialized, setIsInitialized] = useState(false); + + // Initialize audio pool with multiple instances for overlapping sounds + const initializeAudio = useCallback(() => { + if (typeof window === 'undefined') return; + + const sounds = { + paperRubbing: '/sound/paper_rubbing.m4a', + sharpClick: '/sound/sharp_click.m4a', + slide: '/sound/slide.m4a', + pop: '/sound/pop.m4a', + buttonUp: '/sound/button_up.m4a', + buttonDown: '/sound/button_down.m4a', + }; + + const pool: Record = {}; + + Object.entries(sounds).forEach(([key, src]) => { + pool[key] = []; + // Create 3 instances of each sound for overlapping playback + for (let i = 0; i < 3; i++) { + const audio = new Audio(src); + audio.volume = 0.4; + audio.preload = 'auto'; + + // Handle loading errors gracefully + audio.addEventListener('error', () => { + console.warn(`Failed to load sound: ${src}`); + }); + + pool[key].push(audio); + } + }); + + setAudioPool(pool); + setIsInitialized(true); + }, []); + + // Load sound preference from localStorage + useEffect(() => { + if (typeof window !== 'undefined') { + const savedPreference = localStorage.getItem('soundEnabled'); + if (savedPreference !== null) { + setIsSoundEnabled(JSON.parse(savedPreference)); + } + + // Check for reduced motion preference + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + if (prefersReducedMotion && savedPreference === null) { + setIsSoundEnabled(false); + } + + initializeAudio(); + } + }, [initializeAudio]); + + // Save sound preference to localStorage + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('soundEnabled', JSON.stringify(isSoundEnabled)); + } + }, [isSoundEnabled]); + + // Generic sound player with pooling + const playSound = useCallback( + (soundKey: string) => { + if (!isSoundEnabled || !isInitialized || !audioPool[soundKey]) return; + + try { + // Find an available audio instance (not currently playing) + const availableAudio = audioPool[soundKey].find(audio => audio.paused); + const audioToPlay = availableAudio || audioPool[soundKey][0]; + + if (audioToPlay) { + audioToPlay.currentTime = 0; // Reset to start + audioToPlay.play().catch(() => { + // Silently handle autoplay restrictions + }); + } + } catch { + // Silently handle any playback errors + } + }, + [isSoundEnabled, isInitialized, audioPool], + ); + + // Debounced sound players to prevent overlapping similar sounds + const debouncedPlayers = useCallback(() => { + const debounceMap = new Map(); + + return { + playPaperRubbing: () => { + const key = 'paperRubbing'; + if (debounceMap.has(key)) { + clearTimeout(debounceMap.get(key)!); + } + debounceMap.set( + key, + setTimeout(() => { + playSound(key); + debounceMap.delete(key); + }, 50), + ); + }, + playSharpClick: () => playSound('sharpClick'), + playSlide: () => { + const key = 'slide'; + if (debounceMap.has(key)) { + clearTimeout(debounceMap.get(key)!); + } + debounceMap.set( + key, + setTimeout(() => { + playSound(key); + debounceMap.delete(key); + }, 100), + ); + }, + playPop: () => playSound('pop'), + playButtonUp: () => playSound('buttonUp'), + playButtonDown: () => playSound('buttonDown'), + }; + }, [playSound]); + + const soundPlayers = debouncedPlayers(); + + const toggleSound = useCallback(() => { + setIsSoundEnabled(prev => !prev); + }, []); + + const contextValue: SoundContextType = { + isSoundEnabled, + setIsSoundEnabled, + toggleSound, + ...soundPlayers, + }; + + return ( + + {children} + + ); +}; + +export const useSoundContext = (): SoundContextType => { + const context = useContext(SoundContext); + if (context === undefined) { + throw new Error('useSoundContext must be used within a SoundProvider'); + } + return context; +}; diff --git a/src/contexts/layout.tsx b/src/contexts/layout.tsx index 74f00b2f7b54..7b058c30f8d6 100644 --- a/src/contexts/layout.tsx +++ b/src/contexts/layout.tsx @@ -1,5 +1,6 @@ import KeyboardShortcutDialog from '@/components/layout/KeyboardShortcutDialog'; import ShareDialog from '@/components/ShareDialog'; +import { useAudioEffects } from '@/hooks/useAudioEffects'; import { useIsMounted } from '@/hooks/useIsMounted'; import { ComponentType, @@ -49,6 +50,7 @@ export const LayoutProvider = (props: PropsWithChildren) => { const [readingMode, setReadingModeInternal] = useState(false); const isMounted = useIsMounted(); const [isMacOS, setIsMacOS] = useState(true); + const { isSoundEnabled, playButtonUp, playButtonDown } = useAudioEffects(); const [isShortcutDialogOpen, setIsShortcutDialogOpen] = useState(false); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); @@ -96,8 +98,18 @@ export const LayoutProvider = (props: PropsWithChildren) => { }, []); const toggleReadingMode = useCallback(() => { - setReadingMode(prev => !prev); - }, [setReadingMode]); + setReadingMode(prev => { + if (isSoundEnabled) { + if (prev) { + playButtonDown(); + } else { + playButtonUp(); + } + } + + return !prev; + }); + }, [setReadingMode, isSoundEnabled, playButtonUp, playButtonDown]); useEffect(() => { if (!isMounted()) return; diff --git a/src/hooks/useAudioEffects.ts b/src/hooks/useAudioEffects.ts new file mode 100644 index 000000000000..ca92c762224f --- /dev/null +++ b/src/hooks/useAudioEffects.ts @@ -0,0 +1,26 @@ +import { useSoundContext } from '@/contexts/SoundProvider'; + +/** + * Custom hook for managing audio effects throughout the application. + * Provides sound players that respect the global sound enabled state. + * + * @returns Object containing all sound player functions and sound state + */ +export const useAudioEffects = () => { + const context = useSoundContext(); + + return { + // Sound state + isSoundEnabled: context.isSoundEnabled, + toggleSound: context.toggleSound, + setIsSoundEnabled: context.setIsSoundEnabled, + + // Sound players for different UI interactions + playPaperRubbing: context.playPaperRubbing, // TOC hover - organic, subtle + playSharpClick: context.playSharpClick, // TOC click / precise actions + playSlide: context.playSlide, // Button hover / smooth transitions + playPop: context.playPop, // Alternative satisfying click + playButtonUp: context.playButtonUp, // Toggle ON state + playButtonDown: context.playButtonDown, // Toggle OFF state + }; +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 3139dd489042..aa0fbee4df6c 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -4,6 +4,7 @@ import '@/styles/globals.css'; import type { AppProps } from 'next/app'; import { Toaster } from '@/components/ui/sonner'; import { Web3Provider } from '@/contexts/Web3Provider'; +import { SoundProvider } from '@/contexts/SoundProvider'; import { useEffect } from 'react'; import { useRouter } from 'next/router'; @@ -38,10 +39,12 @@ export default function App({ Component, pageProps }: AppProps) { return ( - - - - + + + + + + ); } diff --git a/src/types/audio.ts b/src/types/audio.ts new file mode 100644 index 000000000000..e76563efc74c --- /dev/null +++ b/src/types/audio.ts @@ -0,0 +1,32 @@ +export type SoundEffectType = + | 'paperRubbing' + | 'sharpClick' + | 'slide' + | 'pop' + | 'buttonUp' + | 'buttonDown'; + +export type ButtonSoundType = 'pop' | 'sharp-click' | 'button-toggle' | 'none'; + +export type HoverSoundType = 'slide' | 'none'; + +export interface AudioPool { + [key: string]: HTMLAudioElement[]; +} + +export interface SoundPreferences { + enabled: boolean; + volume: number; +} + +export interface SoundContextType { + isSoundEnabled: boolean; + setIsSoundEnabled: (enabled: boolean) => void; + playPaperRubbing: () => void; + playSharpClick: () => void; + playSlide: () => void; + playPop: () => void; + playButtonUp: () => void; + playButtonDown: () => void; + toggleSound: () => void; +}