Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/sound/button_down.m4a
Binary file not shown.
Binary file added public/sound/button_up.m4a
Binary file not shown.
Binary file added public/sound/paper_rubbing.m4a
Binary file not shown.
Binary file added public/sound/pop.m4a
Binary file not shown.
Binary file added public/sound/sharp_click.m4a
Binary file not shown.
Binary file added public/sound/slide.m4a
Binary file not shown.
31 changes: 31 additions & 0 deletions src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,7 +21,19 @@ interface HeaderProps {
const Header: React.FC<HeaderProps> = () => {
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 (
<header className="bg-background/80 sticky top-0 z-10 w-full backdrop-blur-md">
<div className="flex h-14 items-center justify-between px-4 py-2.5">
Expand Down Expand Up @@ -63,6 +77,23 @@ const Header: React.FC<HeaderProps> = () => {
<div className="ml-auto flex items-center gap-3.5">
{/* Command palette */}
<CommandPalette />

{/* Sound toggle */}
<button
className="flex cursor-pointer items-center justify-center border-0 bg-transparent p-1 outline-none hover:opacity-95 active:opacity-100"
onClick={handleSoundToggle}
aria-label={isSoundEnabled ? 'Disable sounds' : 'Enable sounds'}
data-sound-enabled={isSoundEnabled ? 'true' : 'false'}
>
<span className="select-none">
{isSoundEnabled ? (
<Volume2Icon className="h-4 w-4 fill-[#333639]" />
) : (
<VolumeOffIcon className="h-4 w-4 fill-[#333639]" />
)}
</span>
</button>

{/* Reading mode toggle */}
<TooltipProvider>
<Tooltip>
Expand Down
15 changes: 11 additions & 4 deletions src/components/layout/TableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -21,6 +22,7 @@ const TableOfContents: React.FC<TableOfContentsProps> = ({ items }) => {
const [activeId, setActiveId] = useState<string>('');
const shouldBlockHeadingObserver = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); // Still needed for blocking observer
const { playSharpClick, isSoundEnabled } = useAudioEffects();

const scrollToId = useCallback((id: string) => {
const element = document.getElementById(id);
Expand Down Expand Up @@ -53,13 +55,17 @@ const TableOfContents: React.FC<TableOfContentsProps> = ({ 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}
</Link>
Expand All @@ -80,7 +86,7 @@ const TableOfContents: React.FC<TableOfContentsProps> = ({ 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',
{
Expand All @@ -93,6 +99,7 @@ const TableOfContents: React.FC<TableOfContentsProps> = ({ items }) => {
e.preventDefault();
scrollToId(item.id);
}}
onMouseEnter={() => isSoundEnabled && playSharpClick()}
>
{item.value}
</Link>
Expand Down
67 changes: 63 additions & 4 deletions src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -35,22 +36,80 @@ const buttonVariants = cva(
},
);

interface ButtonProps
extends React.ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {
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<typeof buttonVariants> & {
asChild?: boolean;
}) {
}: ButtonProps) {
const Comp = asChild ? Slot : 'button';
const {
playSlide,
playPop,
playSharpClick,
playButtonUp,
playButtonDown,
isSoundEnabled,
} = useAudioEffects();

const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
if (enableSounds && isSoundEnabled && soundOnHover === 'slide') {
playSlide();
}

if (onMouseEnter) {
onMouseEnter(e);
}
};

return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
{...props}
/>
);
Expand Down
186 changes: 186 additions & 0 deletions src/contexts/SoundProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<SoundContextType | undefined>(undefined);

interface SoundProviderProps {
children: React.ReactNode;
}

export const SoundProvider: React.FC<SoundProviderProps> = ({ children }) => {
const [isSoundEnabled, setIsSoundEnabled] = useState(true);
const [audioPool, setAudioPool] = useState<
Record<string, HTMLAudioElement[]>
>({});
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<string, HTMLAudioElement[]> = {};

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<string, NodeJS.Timeout>();

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 (
<SoundContext.Provider value={contextValue}>
{children}
</SoundContext.Provider>
);
};

export const useSoundContext = (): SoundContextType => {
const context = useContext(SoundContext);
if (context === undefined) {
throw new Error('useSoundContext must be used within a SoundProvider');
}
return context;
};
Loading