feat(audio): Haus ambient sounds + YouTube player fix
WhiteNoise replaced with full ambient mixer: - 6 categories: Noise, Rain, Nature, Places, Things, Animals - 29 real ambient sounds from Haus (MIT licensed) - Howler.js for playback with looping and per-sound volume - Mix multiple sounds simultaneously - Category tab navigation - 300ms fade in/out for smooth toggling MusicPlayer fixes: - Removed origin param (causes issues behind reverse proxy) - Added onError handler for YouTube errors - Added onStateChange to track playing state - Player container 1x1 opacity:0 instead of offscreen positioning
This commit is contained in:
@@ -17,85 +17,89 @@ export default function MusicPlayer() {
|
||||
const [activeId, setActiveId] = useState<string>('lofi-girl');
|
||||
const [volume, setVolume] = useState(0.3);
|
||||
const [started, setStarted] = useState(false);
|
||||
const [playerReady, setPlayerReady] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const playerRef = useRef<any>(null);
|
||||
const volumeRef = useRef(volume);
|
||||
const activeIdRef = useRef(activeId);
|
||||
const readyResolveRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Keep refs in sync so the API callback sees current values
|
||||
useEffect(() => { volumeRef.current = volume; }, [volume]);
|
||||
useEffect(() => { activeIdRef.current = activeId; }, [activeId]);
|
||||
|
||||
// Load YouTube IFrame API
|
||||
const loadAPI = useCallback(() => {
|
||||
if (document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) {
|
||||
// Already loaded — just wait for YT to be ready
|
||||
if ((window as any).YT?.Player) {
|
||||
createPlayer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://www.youtube.com/iframe_api';
|
||||
document.head.appendChild(tag);
|
||||
|
||||
(window as any).onYouTubeIframeAPIReady = () => {
|
||||
createPlayer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const createPlayer = useCallback(() => {
|
||||
// Load YouTube IFrame API and create player
|
||||
const initPlayer = useCallback(() => {
|
||||
const active = LOFI_PLAYLISTS.find((p) => p.id === activeIdRef.current);
|
||||
if (!active || playerRef.current) return;
|
||||
|
||||
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
|
||||
height: '1',
|
||||
width: '1',
|
||||
videoId: active.videoId,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
controls: 0,
|
||||
loop: 1,
|
||||
playlist: active.videoId,
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
modestbranding: 1,
|
||||
fs: 0,
|
||||
},
|
||||
events: {
|
||||
onReady: (e: any) => {
|
||||
e.target.setVolume(Math.round(volumeRef.current * 100));
|
||||
e.target.playVideo();
|
||||
setPlayerReady(true);
|
||||
const createYT = () => {
|
||||
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
|
||||
height: '1',
|
||||
width: '1',
|
||||
videoId: active.videoId,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
controls: 0,
|
||||
loop: 1,
|
||||
playlist: active.videoId,
|
||||
enablejsapi: 1,
|
||||
modestbranding: 1,
|
||||
fs: 0,
|
||||
rel: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
events: {
|
||||
onReady: (e: any) => {
|
||||
e.target.setVolume(Math.round(volumeRef.current * 100));
|
||||
e.target.playVideo();
|
||||
setPlaying(true);
|
||||
},
|
||||
onStateChange: (e: any) => {
|
||||
// YT.PlayerState.PLAYING = 1, PAUSED = 2, ENDED = 0
|
||||
setPlaying(e.data === 1);
|
||||
},
|
||||
onError: (e: any) => {
|
||||
console.warn('[MusicPlayer] YouTube error:', e.data);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if ((window as any).YT?.Player) {
|
||||
createYT();
|
||||
} else {
|
||||
// Load the script
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://www.youtube.com/iframe_api';
|
||||
document.head.appendChild(tag);
|
||||
(window as any).onYouTubeIframeAPIReady = createYT;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePlay = () => {
|
||||
setStarted(true);
|
||||
loadAPI();
|
||||
initPlayer();
|
||||
};
|
||||
|
||||
const changeStation = (id: string) => {
|
||||
setActiveId(id);
|
||||
if (playerRef.current && playerReady) {
|
||||
if (playerRef.current && (window as any).YT) {
|
||||
const active = LOFI_PLAYLISTS.find((p) => p.id === id);
|
||||
if (active) {
|
||||
playerRef.current.loadVideoById(active.videoId);
|
||||
playerRef.current.setVolume(Math.round(volume * 100));
|
||||
playerRef.current.playVideo();
|
||||
try {
|
||||
playerRef.current.loadVideoById(active.videoId);
|
||||
playerRef.current.setVolume(Math.round(volume * 100));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Volume sync
|
||||
useEffect(() => {
|
||||
if (playerRef.current && playerReady) {
|
||||
playerRef.current.setVolume(Math.round(volume * 100));
|
||||
}
|
||||
}, [volume, playerReady]);
|
||||
try {
|
||||
if (playerRef.current?.setVolume) {
|
||||
playerRef.current.setVolume(Math.round(volume * 100));
|
||||
}
|
||||
} catch {}
|
||||
}, [volume]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
@@ -134,11 +138,8 @@ export default function MusicPlayer() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* YouTube player: NOT display:none (API won't init). Offscreen instead. */}
|
||||
<div
|
||||
id="kira-youtube-player"
|
||||
style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 1, height: 1 }}
|
||||
/>
|
||||
{/* YouTube player: must have real dimensions for the API to work */}
|
||||
<div id="kira-youtube-player" style={{ width: 1, height: 1, opacity: 0, position: 'absolute', pointerEvents: 'none' }} />
|
||||
|
||||
<div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 60 }}>
|
||||
{!started ? (
|
||||
@@ -151,7 +152,7 @@ export default function MusicPlayer() {
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-kira-plum/40 text-xs gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-kira-mint animate-pulse inline-block" />
|
||||
<span className={`w-2 h-2 rounded-full animate-pulse inline-block ${playing ? 'bg-kira-mint' : 'bg-kira-plum/30'}`} />
|
||||
<span>playing {LOFI_PLAYLISTS.find((p) => p.id === activeId)?.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,201 +1,194 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Howl } from 'howler';
|
||||
|
||||
type NoiseType = 'white' | 'pink' | 'brown' | 'rain' | 'cafe';
|
||||
interface AmbientSound {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
src: string;
|
||||
}
|
||||
|
||||
const NOISE_PRESETS: { id: NoiseType; label: string; icon: string }[] = [
|
||||
{ id: 'white', label: 'White', icon: '⚪' },
|
||||
{ id: 'pink', label: 'Pink', icon: '🌸' },
|
||||
{ id: 'brown', label: 'Brown', icon: '🤎' },
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
||||
{ id: 'cafe', label: 'Cafe', icon: '☕' },
|
||||
interface Category {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
sounds: AmbientSound[];
|
||||
}
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 'noise', label: 'Noise', icon: '🔉',
|
||||
sounds: [
|
||||
{ id: 'white-noise', label: 'White', icon: '⚪', src: '/sounds/noise/white-noise.wav' },
|
||||
{ id: 'pink-noise', label: 'Pink', icon: '🌸', src: '/sounds/noise/pink-noise.wav' },
|
||||
{ id: 'brown-noise', label: 'Brown', icon: '🤎', src: '/sounds/noise/brown-noise.wav' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rain', label: 'Rain', icon: '🌧️',
|
||||
sounds: [
|
||||
{ id: 'light-rain', label: 'Light', icon: '🌦️', src: '/sounds/rain/light-rain.mp3' },
|
||||
{ id: 'heavy-rain', label: 'Heavy', icon: '⛈️', src: '/sounds/rain/heavy-rain.mp3' },
|
||||
{ id: 'rain-on-window', label: 'Window', icon: '🪟', src: '/sounds/rain/rain-on-window.mp3' },
|
||||
{ id: 'thunder', label: 'Thunder', icon: '⚡', src: '/sounds/rain/thunder.mp3' },
|
||||
{ id: 'rain-on-tent', label: 'Tent', icon: '⛺', src: '/sounds/rain/rain-on-tent.mp3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nature', label: 'Nature', icon: '🌿',
|
||||
sounds: [
|
||||
{ id: 'waves', label: 'Waves', icon: '🌊', src: '/sounds/nature/waves.mp3' },
|
||||
{ id: 'campfire', label: 'Campfire', icon: '🔥', src: '/sounds/nature/campfire.mp3' },
|
||||
{ id: 'river', label: 'River', icon: '🏞️', src: '/sounds/nature/river.mp3' },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨', src: '/sounds/nature/wind.mp3' },
|
||||
{ id: 'jungle', label: 'Jungle', icon: '🌴', src: '/sounds/nature/jungle.mp3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'places', label: 'Places', icon: '☕',
|
||||
sounds: [
|
||||
{ id: 'cafe', label: 'Cafe', icon: '☕', src: '/sounds/places/cafe.mp3' },
|
||||
{ id: 'library', label: 'Library', icon: '📚', src: '/sounds/places/library.mp3' },
|
||||
{ id: 'office', label: 'Office', icon: '🏢', src: '/sounds/places/office.mp3' },
|
||||
{ id: 'restaurant', label: 'Restaurant', icon: '🍽️', src: '/sounds/places/restaurant.mp3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'things', label: 'Things', icon: '🎵',
|
||||
sounds: [
|
||||
{ id: 'keyboard', label: 'Keyboard', icon: '⌨️', src: '/sounds/things/keyboard.mp3' },
|
||||
{ id: 'clock', label: 'Clock', icon: '🕐', src: '/sounds/things/clock.mp3' },
|
||||
{ id: 'ceiling-fan', label: 'Fan', icon: '🌀', src: '/sounds/things/ceiling-fan.mp3' },
|
||||
{ id: 'vinyl-effect', label: 'Vinyl', icon: '💿', src: '/sounds/things/vinyl-effect.mp3' },
|
||||
{ id: 'wind-chimes', label: 'Chimes', icon: '🎐', src: '/sounds/things/wind-chimes.mp3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'animals', label: 'Animals', icon: '🐾',
|
||||
sounds: [
|
||||
{ id: 'birds', label: 'Birds', icon: '🐦', src: '/sounds/animals/birds.mp3' },
|
||||
{ id: 'cat-purring', label: 'Cat', icon: '🐱', src: '/sounds/animals/cat-purring.mp3' },
|
||||
{ id: 'crickets', label: 'Crickets', icon: '🦗', src: '/sounds/animals/crickets.mp3' },
|
||||
{ id: 'owl', label: 'Owl', icon: '🦉', src: '/sounds/animals/owl.mp3' },
|
||||
{ id: 'whale', label: 'Whale', icon: '🐋', src: '/sounds/animals/whale.mp3' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhiteNoise() {
|
||||
const [active, setActive] = useState<NoiseType | null>(null);
|
||||
const [volume, setVolume] = useState(0.25);
|
||||
const ctxRef = useRef<AudioContext | null>(null);
|
||||
const gainRef = useRef<GainNode | null>(null);
|
||||
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
// For rain/cafe: extra nodes to tear down
|
||||
const extraNodesRef = useRef<AudioNode[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<string>('noise');
|
||||
const [playing, setPlaying] = useState<Record<string, number>>({}); // id -> volume 0-1
|
||||
const howlsRef = useRef<Map<string, Howl>>(new Map());
|
||||
|
||||
const stopNoise = useCallback(() => {
|
||||
if (sourceRef.current) {
|
||||
try { sourceRef.current.stop(); } catch {}
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
const activeCategory = CATEGORIES.find((c) => c.id === activeTab) ?? CATEGORIES[0];
|
||||
|
||||
const toggle = useCallback((sound: AmbientSound) => {
|
||||
const existing = howlsRef.current.get(sound.id);
|
||||
if (existing && existing.playing()) {
|
||||
existing.fade(existing.volume(), 0, 300);
|
||||
setTimeout(() => {
|
||||
existing.stop();
|
||||
existing.unload();
|
||||
howlsRef.current.delete(sound.id);
|
||||
}, 300);
|
||||
setPlaying((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[sound.id];
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
const howl = new Howl({
|
||||
src: [sound.src],
|
||||
loop: true,
|
||||
volume: 0.5,
|
||||
html5: true,
|
||||
});
|
||||
howl.fade(0, 0.5, 300);
|
||||
howl.play();
|
||||
howlsRef.current.set(sound.id, howl);
|
||||
setPlaying((prev) => ({ ...prev, [sound.id]: 0.5 }));
|
||||
}
|
||||
extraNodesRef.current.forEach((n) => { try { n.disconnect(); } catch {} });
|
||||
extraNodesRef.current = [];
|
||||
setActive(null);
|
||||
}, []);
|
||||
|
||||
const startNoise = useCallback((type: NoiseType) => {
|
||||
stopNoise();
|
||||
const setSoundVolume = useCallback((id: string, vol: number) => {
|
||||
const howl = howlsRef.current.get(id);
|
||||
if (howl) howl.volume(vol);
|
||||
setPlaying((prev) => ({ ...prev, [id]: vol }));
|
||||
}, []);
|
||||
|
||||
const ctx = ctxRef.current || new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
ctxRef.current = ctx;
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
|
||||
const sr = ctx.sampleRate;
|
||||
// 10-second buffer — long enough to avoid obvious loops, short enough to generate fast
|
||||
const len = 10 * sr;
|
||||
const buf = ctx.createBuffer(2, len, sr); // stereo for spatial depth
|
||||
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const d = buf.getChannelData(ch);
|
||||
if (type === 'white') {
|
||||
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||||
} else if (type === 'pink') {
|
||||
// Paul Kellet's pink noise filter (industry standard)
|
||||
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
b0 = 0.99886 * b0 + w * 0.0555179;
|
||||
b1 = 0.99332 * b1 + w * 0.0750759;
|
||||
b2 = 0.96900 * b2 + w * 0.1538520;
|
||||
b3 = 0.86650 * b3 + w * 0.3104856;
|
||||
b4 = 0.55000 * b4 + w * 0.5329522;
|
||||
b5 = -0.7616 * b5 - w * 0.0168980;
|
||||
d[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.11;
|
||||
b6 = w * 0.115926;
|
||||
}
|
||||
} else if (type === 'brown') {
|
||||
// Brownian motion (integrated white noise)
|
||||
let last = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
last += (Math.random() * 2 - 1) * 0.02;
|
||||
last = Math.max(-1, Math.min(1, last)); // clamp
|
||||
d[i] = last;
|
||||
}
|
||||
} else if (type === 'rain') {
|
||||
// Layered: low rumble (brown) + random droplet clicks
|
||||
let last = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
// Brown base for rumble
|
||||
last += w * 0.005;
|
||||
last *= 0.998;
|
||||
// Random droplet pings (sparse impulses filtered)
|
||||
const drop = Math.random() < 0.0008 ? (Math.random() * 0.6 + 0.2) : 0;
|
||||
d[i] = last * 0.6 + drop * Math.sin(i * 0.13 + ch) * 0.4;
|
||||
}
|
||||
} else if (type === 'cafe') {
|
||||
// Low brown hum + scattered high transients (clatter)
|
||||
let last = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
last += w * 0.003;
|
||||
last *= 0.999;
|
||||
// Occasional clatter
|
||||
const clatter = Math.random() < 0.0005 ? (Math.random() * 0.4 + 0.1) * Math.sin(i * 0.47) : 0;
|
||||
// Distant murmur (band-limited noise burst)
|
||||
const murmur = Math.random() * 0.02;
|
||||
d[i] = last * 0.5 + clatter + murmur;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Crossfade start/end for seamless loop
|
||||
const fade = Math.min(sr * 0.05, len / 4); // 50ms fade
|
||||
for (let i = 0; i < fade; i++) {
|
||||
const t = i / fade;
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const d = buf.getChannelData(ch);
|
||||
d[i] *= t; // fade in
|
||||
d[len - 1 - i] *= t; // fade out
|
||||
}
|
||||
}
|
||||
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.loop = true;
|
||||
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = volume;
|
||||
|
||||
let tail: AudioNode = src;
|
||||
|
||||
// For rain/cafe: add filtering for warmth
|
||||
if (type === 'rain') {
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 6000;
|
||||
lp.Q.value = 0.5;
|
||||
tail.connect(lp);
|
||||
tail = lp;
|
||||
extraNodesRef.current.push(lp);
|
||||
} else if (type === 'cafe') {
|
||||
const bp = ctx.createBiquadFilter();
|
||||
bp.type = 'lowpass';
|
||||
bp.frequency.value = 3000;
|
||||
bp.Q.value = 0.8;
|
||||
tail.connect(bp);
|
||||
tail = bp;
|
||||
extraNodesRef.current.push(bp);
|
||||
}
|
||||
|
||||
tail.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
src.start();
|
||||
|
||||
sourceRef.current = src;
|
||||
gainRef.current = gain;
|
||||
setActive(type);
|
||||
}, [volume, stopNoise]);
|
||||
|
||||
const toggle = (type: NoiseType) => {
|
||||
if (active === type) stopNoise();
|
||||
else startNoise(type);
|
||||
};
|
||||
|
||||
const changeVolume = (v: number) => {
|
||||
setVolume(v);
|
||||
if (gainRef.current) gainRef.current.gain.value = v;
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => {
|
||||
stopNoise();
|
||||
ctxRef.current?.close().catch(() => {});
|
||||
}, [stopNoise]);
|
||||
// Cleanup all on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
howlsRef.current.forEach((h) => { try { h.stop(); h.unload(); } catch {} });
|
||||
howlsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||
<span>🌬️</span> White Noise
|
||||
<span>🌬️</span> Ambient
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{NOISE_PRESETS.map((p) => (
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="flex gap-1 mb-3 flex-wrap">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => toggle(p.id)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full transition-all flex items-center gap-1 ${
|
||||
active === p.id
|
||||
? 'bg-kira-pink text-white shadow'
|
||||
: 'bg-white/50 text-kira-plum/70 hover:bg-kira-glow'
|
||||
key={cat.id}
|
||||
onClick={() => setActiveTab(cat.id)}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg transition-all flex items-center gap-1 ${
|
||||
activeTab === cat.id
|
||||
? 'bg-kira-pink/20 text-kira-pink font-bold'
|
||||
: 'text-kira-plum/40 hover:text-kira-plum/60'
|
||||
}`}
|
||||
>
|
||||
<span>{p.icon}</span>
|
||||
<span>{p.label}</span>
|
||||
<span>{cat.icon}</span>
|
||||
<span>{cat.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-kira-plum/60">
|
||||
<span>Vol</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.6}
|
||||
step={0.02}
|
||||
value={volume}
|
||||
onChange={(e) => changeVolume(parseFloat(e.target.value))}
|
||||
className="flex-1 accent-kira-pink"
|
||||
/>
|
||||
<span className="tabular-nums w-8 text-right">{Math.round(volume * 100)}%</span>
|
||||
|
||||
{/* Sound grid */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeCategory.sounds.map((sound) => {
|
||||
const vol = playing[sound.id];
|
||||
const isActive = vol !== undefined;
|
||||
return (
|
||||
<div key={sound.id} className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggle(sound)}
|
||||
className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-sm transition-all ${
|
||||
isActive
|
||||
? 'bg-kira-pink text-white shadow'
|
||||
: 'bg-white/40 text-kira-plum/50 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
{sound.icon}
|
||||
</button>
|
||||
<span className={`text-[11px] w-14 shrink-0 ${isActive ? 'text-kira-plum font-medium' : 'text-kira-plum/40'}`}>
|
||||
{sound.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.02}
|
||||
value={vol}
|
||||
onChange={(e) => setSoundVolume(sound.id, parseFloat(e.target.value))}
|
||||
className="flex-1 accent-kira-pink"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{active && (
|
||||
<div className="mt-2 text-[10px] text-kira-mint/70">Playing {active} noise — body double approved ✨</div>
|
||||
|
||||
{/* Active sounds count */}
|
||||
{Object.keys(playing).length > 0 && (
|
||||
<div className="mt-2 text-[10px] text-kira-mint/70">
|
||||
{Object.keys(playing).length} sound{Object.keys(playing).length > 1 ? 's' : ''} mixing ✨
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user