diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..a96b99e --- /dev/null +++ b/README.en.md @@ -0,0 +1,95 @@ +## 🌍 Language / 语言 + +**[English](README.en.md)** | **[简体中文](README.md)** + +--- + +
+ Moodist Logo Banner +

Moodist 🌲

+

Ambient sounds for focus and calm.

+ Visit Moodist | Buy Me a Coffee +
+ +## Table of Contents + +- ⚡ [Features](#features) +- 🧰 [Tools](#tools) +- 🔮 [Commands](#commands) +- 🚧 [Contributing](#contributing) +- ⭐ [Support](#support-moodist) +- 📜 [License](#license) + +## Features + +1. 🎵 Over 75 ambient sounds. +1. 📝 Persistent sound selection. +1. ✈️ Sharing sound selections with others. +1. 🧰 Custom sound presets. +1. 🌙 Sleep timer for sounds. +1. 📓 Notepad for quick notes. +1. 🍅 Pomodoro timer. +1. ✅ Simple to-do list (soon). +1. ⏯️ Media controls. +1. ⌨️ Keyboard shortcuts for everything. +1. 🥷 Privacy focused: no data collection. +1. 💰 Completely free, open-source, and self-hostable. + +## Tools + +- ⚡ **TypeScript**: Programming Language +- 🔨 **React**: UI Library +- 🧑‍🚀 **Astro**: Meta Framework +- 🎨 **CSS Modules**: Styling +- 🐻 **Zustand**: State Management +- 🎭 **Framer Motion**: Animation Library +- ⚙️ **Radix**: Accessible Components +- 📕 **Storybook**: Component Documentation +- 🧪 **Vitest**: Unit Testing (soon) +- 🔭 **Playwright**: End-To-End Testing (soon) +- 🔍 **ESLint**: Code Linting +- 🧹 **Prettier**: Code Formatting +- 🧼 **Stylelint**: CSS Linting +- 🐶 **Husky**: Git Hooks +- 📝 **Lint Staged**: Running Linters on Staged Files +- 🧽 **Commitlint**: Git Commit Linting +- 🧭 **Commitizen**: Git Commit Message Helper +- 📓 **Standard Version**: Versioning and CHANGLOG Generation +- 🧰 **PostCSS**: CSS Transformations + +## Commands + +- `npm run dev`: run development server +- `npm run build`: build for production +- `npm run preview`: preview the built app +- `npm run lint`: lint files using ESLint +- `npm run lint:fix`: lint and fix using ESLint +- `npm run lint:style`: lint styles using Stylelint +- `npm run lint:style:fix`: lint and fix styles using Stylelint +- `npm run format`: format files using Prettier +- `npm run commit`: commit message using Commitizen +- `npm run release:major`: release major version +- `npm run release:minor`: release minor version +- `npm run release:patch`: release patch version +- `npm run storybook`: run Storybook + +## Contributing + +🚧 Please check [CONTRIBUTING.md](CONTRIBUTING.md) file. + +## Support Moodist + +⭐ Give a star if you liked this project. + +☕ [Buy Me a Coffee](https://buymeacoffee.com/remvze) to help me maintain Moodist. + +## License + +This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. + +### ⚠️ Third-Party Assets + +Some sounds used in this project are sourced from third-party providers and **are subject to different licenses**: + +- Sounds licensed under the **Pixabay Content License**: [Pixabay Content License](https://pixabay.com/service/license-summary/) +- Sounds licensed under **CC0**: [Creative Commons Zero License](https://creativecommons.org/publicdomain/zero/1.0/) \ No newline at end of file diff --git a/README.md b/README.md index 9f11f04..a50582d 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,157 @@ ## 🌍 Language / 语言 -**[English](README.md)** | **[简体中文](README.zh-CN.md)** +**[English](README.en.md)** | **[简体中文](README.md)** ---
Moodist Logo Banner

Moodist 🌲

-

Ambient sounds for focus and calm.

- Visit Moodist | Buy Me a Coffee +

环境音工具,助你专注与平静

+ 访问 Moodist | 在线体验地址 | 支持开发者
-## Table of Contents +## 目录 -- ⚡ [Features](#features) -- 🧰 [Tools](#tools) -- 🔮 [Commands](#commands) -- 🚧 [Contributing](#contributing) -- ⭐ [Support](#support-moodist) -- 📜 [License](#license) +- ⚡ [功能特性](#features) +- 🎮 [使用说明](#-使用说明) +- 🐳 [Docker 部署](#-docker-部署) +- 🌐 [在线体验](#-在线体验) +- 🧰 [技术栈](#tools) +- 🔮 [命令](#commands) +- 🚧 [贡献指南](#contributing) +- ⭐ [支持项目](#support-moodist) +- 📜 [许可证](#license) -## Features +## 功能特性 -1. 🎵 Over 75 ambient sounds. -1. 📝 Persistent sound selection. -1. ✈️ Sharing sound selections with others. -1. 🧰 Custom sound presets. -1. 🌙 Sleep timer for sounds. -1. 📓 Notepad for quick notes. -1. 🍅 Pomodoro timer. -1. ✅ Simple to-do list (soon). -1. ⏯️ Media controls. -1. ⌨️ Keyboard shortcuts for everything. -1. 🥷 Privacy focused: no data collection. -1. 💰 Completely free, open-source, and self-hostable. +1. 🎵 75+ 种环境音效 +2. 📝 声音选择持久化存储 +3. ✈️ 分享声音组合给他人 +4. 🧰 自定义声音预设 +5. 🌙 声音睡眠定时器 +6. 📓 便签快速记录 +7. 🍅 番茄钟计时器 +8. ✅ 简单待办事项(即将推出) +9. ⏯️ 媒体控制键 +10. ⌨️ 全功能快捷键支持 +11. 🥷 隐私保护:无数据收集 +12. 💰 完全免费、开源、可自托管 -## Tools +## 🎮 使用说明 + +### 基本操作 +- **播放/暂停声音**:点击声音卡片即可播放,再次点击暂停 +- **音量调节**:拖动声音卡片下方的音量进度条 +- **速度调节**:拖动第二条进度条调整播放速度 +- **音调调节**:拖动第三条进度条调整音调(Rate) + +### 高级功能 +- **收藏功能**:点击声音卡片右上角的❤️图标收藏常用声音 +- **随机效果**:点击❤️下方的🔀图标启用随机变化: + - 每次只随机调整一个参数(速度/音调/音量) + - 随机变化频率约为1分钟一次 + - 速度和音调:默认值 ±0.25 范围内随机 + - 音量:30%-70% 范围内随机 +- **键盘快捷键**: + - 空格键:播放/暂停 + - 方向键:调节选中声音的音量 + - 数字键:快速选择声音 + +### 主题切换 +- **昼夜模式**:点击右上角的🌞/🌙按钮切换主题 +- **自动适配**:系统会根据您的设备主题自动选择合适的颜色方案 +- **全面适配**:主题切换会影响整个页面背景及所有组件的颜色 + +## 🐳 Docker 部署 + +### 使用 Docker Compose(推荐) + +1. **克隆项目** + ```bash + git clone https://github.com/your-username/moodist.git + cd moodist + ``` + +2. **创建 docker-compose.yml 文件** + ```yaml + version: '3.8' + + services: + moodist: + build: . + ports: + - "4321:4321" + environment: + - NODE_ENV=production + restart: unless-stopped + ``` + +3. **启动服务** + ```bash + docker-compose up -d + ``` + +4. **访问应用** + + 打开浏览器访问:http://localhost:4321 + +### 使用 Docker 命令 + +1. **构建镜像** + ```bash + docker build -t moodist . + ``` + +2. **运行容器** + ```bash + docker run -d -p 4321:4321 --name moodist moodist + ``` + +3. **访问应用** + + 打开浏览器访问:http://localhost:4321 + +### 生产环境部署 + +对于生产环境,建议使用反向代理(如 Nginx)并配置 HTTPS: + +```nginx +server { + listen 80; + server_name your-domain.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl; + server_name your-domain.com; + + ssl_certificate /path/to/certificate.crt; + ssl_certificate_key /path/to/private.key; + + location / { + proxy_pass http://localhost:4321; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### 环境变量配置 + +- `NODE_ENV`: 设置为 `production` 以启用生产模式优化 +- `PORT`: 应用运行端口(默认:4321) + +## 🌐 在线体验 + +- **官方站点**:https://moodist.mvze.net +- **体验地址**:https://calm.zlext.com(可直接使用) +- **完全免费**:无需注册,即开即用 + +## 技术栈 - ⚡ **TypeScript**: Programming Language - 🔨 **React**: UI Library @@ -57,33 +173,33 @@ - 📓 **Standard Version**: Versioning and CHANGLOG Generation - 🧰 **PostCSS**: CSS Transformations -## Commands +## 命令 -- `npm run dev`: run development server -- `npm run build`: build for production -- `npm run preview`: preview the built app -- `npm run lint`: lint files using ESLint -- `npm run lint:fix`: lint and fix using ESLint -- `npm run lint:style`: lint styles using Stylelint -- `npm run lint:style:fix`: lint and fix styles using Stylelint -- `npm run format`: format files using Prettier -- `npm run commit`: commit message using Commitizen -- `npm run release:major`: release major version -- `npm run release:minor`: release minor version -- `npm run release:patch`: release patch version -- `npm run storybook`: run Storybook +- `npm run dev`: 启动开发服务器 +- `npm run build`: 构建生产版本 +- `npm run preview`: 预览构建的应用 +- `npm run lint`: 使用 ESLint 检查代码 +- `npm run lint:fix`: 使用 ESLint 检查并修复代码 +- `npm run lint:style`: 使用 Stylelint 检查样式 +- `npm run lint:style:fix`: 使用 Stylelint 检查并修复样式 +- `npm run format`: 使用 Prettier 格式化代码 +- `npm run commit`: 使用 Commitizen 提交代码 +- `npm run release:major`: 发布主版本 +- `npm run release:minor`: 发布次版本 +- `npm run release:patch`: 发布补丁版本 +- `npm run storybook`: 运行 Storybook -## Contributing +## 贡献指南 -🚧 Please check [CONTRIBUTING.md](CONTRIBUTING.md) file. +🚧 请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 文件。 -## Support Moodist +## 支持项目 -⭐ Give a star if you liked this project. +⭐ 如果您喜欢这个项目,请给我们一个星标。 -☕ [Buy Me a Coffee](https://buymeacoffee.com/remvze) to help me maintain Moodist. +☕ [请我喝咖啡](https://buymeacoffee.com/remvze) 来帮助我维护 Moodist。 -## License +## 许可证 This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index 656036c..de62692 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -15,6 +15,7 @@ import { SharedModal } from '@/components/modals/shared'; import { Toolbar } from '@/components/toolbar'; import { SnackbarProvider } from '@/contexts/snackbar'; import { MediaControls } from '@/components/media-controls'; +import { ThemeToggle } from '@/components/theme-toggle'; import { sounds } from '@/data/sounds'; import { FADE_OUT } from '@/constants/events'; @@ -93,6 +94,7 @@ export function App() { return ( +
diff --git a/src/components/sounds/sound/favorite/favorite.module.css b/src/components/sounds/sound/favorite/favorite.module.css index fb74e40..c647e59 100644 --- a/src/components/sounds/sound/favorite/favorite.module.css +++ b/src/components/sounds/sound/favorite/favorite.module.css @@ -1,7 +1,4 @@ .favoriteButton { - position: absolute; - top: 10px; - right: 10px; display: flex; align-items: center; justify-content: center; @@ -10,23 +7,24 @@ line-height: 0; color: var(--color-foreground-subtle); cursor: pointer; - background-color: black; - background-color: var(--color-neutral-100); - border: 1px solid var(--color-neutral-200); + background-color: var(--component-bg); + border: 1px solid var(--color-border); border-radius: 50%; transition: 0.2s; &:hover, &:focus-visible { color: var(--color-foreground); + background-color: var(--component-hover); } &:focus-visible { - outline: 2px solid var(--color-neutral-400); + outline: 2px solid var(--color-muted); outline-offset: 2px; } &.isFavorite { color: var(--color-foreground); + background-color: var(--component-active); } } diff --git a/src/components/sounds/sound/random-speed/index.ts b/src/components/sounds/sound/random-speed/index.ts new file mode 100644 index 0000000..a72a033 --- /dev/null +++ b/src/components/sounds/sound/random-speed/index.ts @@ -0,0 +1 @@ +export { RandomSpeed } from './random-speed'; \ No newline at end of file diff --git a/src/components/sounds/sound/random-speed/random-speed.module.css b/src/components/sounds/sound/random-speed/random-speed.module.css new file mode 100644 index 0000000..99cf400 --- /dev/null +++ b/src/components/sounds/sound/random-speed/random-speed.module.css @@ -0,0 +1,30 @@ +.randomSpeedButton { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + line-height: 0; + color: var(--color-foreground-subtle); + cursor: pointer; + background-color: var(--component-bg); + border: 1px solid var(--color-border); + border-radius: 50%; + transition: 0.2s; + + &:hover, + &:focus-visible { + color: var(--color-foreground); + background-color: var(--component-hover); + } + + &:focus-visible { + outline: 2px solid var(--color-muted); + outline-offset: 2px; + } + + &.isRandomSpeed { + color: var(--color-foreground); + background-color: var(--component-active); + } +} \ No newline at end of file diff --git a/src/components/sounds/sound/random-speed/random-speed.tsx b/src/components/sounds/sound/random-speed/random-speed.tsx new file mode 100644 index 0000000..7edc8e6 --- /dev/null +++ b/src/components/sounds/sound/random-speed/random-speed.tsx @@ -0,0 +1,89 @@ +import { FiShuffle } from 'react-icons/fi/index'; +import { AnimatePresence, motion } from 'motion/react'; + +import { useSoundStore } from '@/stores/sound'; +import { cn } from '@/helpers/styles'; +import { fade } from '@/lib/motion'; + +import styles from './random-speed.module.css'; + +import { useKeyboardButton } from '@/hooks/use-keyboard-button'; +import { random } from '@/helpers/random'; + +interface RandomSpeedProps { + id: string; + label: string; + baseSpeed: number; + baseRate: number; + baseVolume: number; +} + +export function RandomSpeed({ id, label, baseSpeed, baseRate, baseVolume }: RandomSpeedProps) { + const isRandomSpeed = useSoundStore(state => state.sounds[id].isRandomSpeed); + const isRandomVolume = useSoundStore(state => state.sounds[id].isRandomVolume); + const isRandomRate = useSoundStore(state => state.sounds[id].isRandomRate); + const toggleAllRandom = useSoundStore(state => state.toggleAllRandom); + const setSpeed = useSoundStore(state => state.setSpeed); + const setRate = useSoundStore(state => state.setRate); + const setVolume = useSoundStore(state => state.setVolume); + + const hasAnyRandom = isRandomSpeed || isRandomVolume || isRandomRate; + + const handleToggle = () => { + toggleAllRandom(id); + + if (!hasAnyRandom) { + // 启用随机时,立即设置随机值 + if (isRandomSpeed) { + const randomSpeed = random(baseSpeed - 0.25, baseSpeed + 0.25); + setSpeed(id, Math.max(0.5, Math.min(2.0, randomSpeed))); + } + if (isRandomRate) { + const randomRate = random(baseRate - 0.25, baseRate + 0.25); + setRate(id, Math.max(0.5, Math.min(2.0, randomRate))); + } + if (isRandomVolume) { + const randomVolume = random(baseVolume * 0.3, baseVolume * 0.7); + setVolume(id, Math.max(0.0, Math.min(1.0, randomVolume))); + } + } else { + // 禁用随机时,恢复基础值 + setSpeed(id, baseSpeed); + setRate(id, baseRate); + setVolume(id, baseVolume); + } + }; + + const variants = fade(); + + const handleKeyDown = useKeyboardButton(handleToggle); + + return ( + + + + ); +} \ No newline at end of file diff --git a/src/components/sounds/sound/range/range.module.css b/src/components/sounds/sound/range/range.module.css index d195001..e36bb7f 100644 --- a/src/components/sounds/sound/range/range.module.css +++ b/src/components/sounds/sound/range/range.module.css @@ -1,7 +1,47 @@ +.controlsContainer { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; + width: 100%; + max-width: 150px; +} + +.volumeContainer, +.speedContainer, +.rateContainer { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + +.volumeIcon, +.speedIcon, +.rateIcon { + color: var(--color-foreground-subtle); + font-size: 14px; + flex-shrink: 0; + opacity: 0.8; + transition: opacity 0.2s ease; +} + +.volumeContainer:hover .volumeIcon, +.speedContainer:hover .speedIcon, +.rateContainer:hover .rateIcon { + opacity: 1; +} + +/* 当进度条禁用时,容器内的图标也要相应调整 */ +.volumeContainer:has(.range:disabled) .volumeIcon, +.speedContainer:has(.range:disabled) .speedIcon, +.rateContainer:has(.range:disabled) .rateIcon { + opacity: 0.4; +} + .range { width: 100%; - max-width: 120px; - margin-top: 10px; + flex: 1; /********** Range Input Styles **********/ @@ -25,7 +65,7 @@ &::-webkit-slider-runnable-track { height: 0.5rem; - background-color: #27272a; + background-color: var(--bg-secondary); border-radius: 0.5rem; } @@ -34,14 +74,14 @@ height: 14px; margin-top: -3px; appearance: none; - background-color: #3f3f46; - border: 1px solid #52525b; + background-color: var(--bg-tertiary); + border: 1px solid var(--color-border); border-radius: 50%; } &:not(:disabled):focus::-webkit-slider-thumb { - border: 1px solid #053a5f; - outline: 3px solid #053a5f; + border: 1px solid var(--color-foreground); + outline: 3px solid var(--color-foreground); outline-offset: 0.125rem; } @@ -49,7 +89,7 @@ &::-moz-range-track { height: 0.5rem; - background-color: #27272a; + background-color: var(--bg-secondary); border-radius: 0.5rem; } @@ -57,16 +97,16 @@ width: 14px; height: 14px; margin-top: -3px; - background-color: #3f3f46; + background-color: var(--bg-tertiary); border: none; - border: 1px solid #52525b; + border: 1px solid var(--color-border); border-radius: 0; border-radius: 50%; } &:not(:disabled):focus::-moz-range-thumb { - border: 1px solid #053a5f; - outline: 3px solid #053a5f; + border: 1px solid var(--color-foreground); + outline: 3px solid var(--color-foreground); outline-offset: 0.125rem; } } diff --git a/src/components/sounds/sound/range/range.tsx b/src/components/sounds/sound/range/range.tsx index 0c08943..9dbd161 100644 --- a/src/components/sounds/sound/range/range.tsx +++ b/src/components/sounds/sound/range/range.tsx @@ -1,3 +1,4 @@ +import { FaVolumeUp, FaTachometerAlt, FaMusic } from 'react-icons/fa/index'; import { useSoundStore } from '@/stores/sound'; import { useTranslation } from '@/hooks/useTranslation'; @@ -11,24 +12,69 @@ interface RangeProps { export function Range({ id, label }: RangeProps) { const { t } = useTranslation(); const setVolume = useSoundStore(state => state.setVolume); + const setSpeed = useSoundStore(state => state.setSpeed); + const setRate = useSoundStore(state => state.setRate); const volume = useSoundStore(state => state.sounds[id].volume); + const speed = useSoundStore(state => state.sounds[id].speed); + const rate = useSoundStore(state => state.sounds[id].rate); const isSelected = useSoundStore(state => state.sounds[id].isSelected); const locked = useSoundStore(state => state.locked); return ( - e.stopPropagation()} - onChange={e => - !locked && isSelected && setVolume(id, Number(e.target.value) / 100) - } - /> +
+
+ + e.stopPropagation()} + onChange={e => + !locked && isSelected && setVolume(id, Number(e.target.value) / 100) + } + /> +
+
+ + e.stopPropagation()} + onChange={e => + !locked && isSelected && setSpeed(id, Number(e.target.value) / 100) + } + /> +
+
+ + e.stopPropagation()} + onChange={e => + !locked && isSelected && setRate(id, Number(e.target.value) / 100) + } + /> +
+
); } diff --git a/src/components/sounds/sound/sound.module.css b/src/components/sounds/sound/sound.module.css index d7bfb49..dad7489 100644 --- a/src/components/sounds/sound/sound.module.css +++ b/src/components/sounds/sound/sound.module.css @@ -7,13 +7,13 @@ padding: 25px 20px; text-align: center; cursor: pointer; - background: linear-gradient(rgb(24 24 27 / 50%), transparent); - border: 1px solid var(--color-neutral-200); + background: linear-gradient(var(--component-bg) / 50%, transparent); + border: 1px solid var(--color-border); border-radius: 12px; transition: 0.2s; &:focus-visible { - outline: 2px solid var(--color-neutral-400); + outline: 2px solid var(--color-muted); outline-offset: 2px; } @@ -31,7 +31,7 @@ background: linear-gradient( 90deg, transparent, - var(--color-neutral-400), + var(--color-muted), transparent ); } @@ -60,7 +60,7 @@ width: 100%; height: 100%; content: ''; - background-color: var(--color-neutral-50); + background-color: var(--bg-tertiary); border-radius: 50%; } @@ -73,8 +73,8 @@ height: calc(100% + 2px); content: ''; background: linear-gradient( - var(--color-neutral-300), - var(--color-neutral-100) + var(--bg-quaternary), + var(--bg-secondary) ); border-radius: 50%; } @@ -95,7 +95,7 @@ &.selected { border-color: transparent; - box-shadow: 0 0 0 2px var(--color-neutral-800); + box-shadow: 0 0 0 2px var(--color-border); & .icon { color: var(--color-foreground); @@ -111,6 +111,17 @@ } } +.controlsContainer { + position: absolute; + top: 8px; + right: 8px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 10; + align-items: flex-end; +} + @keyframes spinner { 0% { transform: rotate(0deg); diff --git a/src/components/sounds/sound/sound.tsx b/src/components/sounds/sound/sound.tsx index 8ba190f..80fa69a 100644 --- a/src/components/sounds/sound/sound.tsx +++ b/src/components/sounds/sound/sound.tsx @@ -3,6 +3,7 @@ import { ImSpinner9 } from 'react-icons/im/index'; import { Range } from './range'; import { Favorite } from './favorite'; +import { RandomSpeed } from './random-speed'; import { useSound } from '@/hooks/use-sound'; import { useSoundStore } from '@/stores/sound'; @@ -31,19 +32,30 @@ export const Sound = forwardRef(function Sound( const selectSound = useSoundStore(state => state.select); const unselectSound = useSoundStore(state => state.unselect); const setVolume = useSoundStore(state => state.setVolume); + const setSpeed = useSoundStore(state => state.setSpeed); + const setRate = useSoundStore(state => state.setRate); const isSelected = useSoundStore(state => state.sounds[id].isSelected); const locked = useSoundStore(state => state.locked); const volume = useSoundStore(state => state.sounds[id].volume); + const speed = useSoundStore(state => state.sounds[id].speed); + const rate = useSoundStore(state => state.sounds[id].rate); + const isRandomSpeed = useSoundStore(state => state.sounds[id].isRandomSpeed); + const isRandomVolume = useSoundStore(state => state.sounds[id].isRandomVolume); + const isRandomRate = useSoundStore(state => state.sounds[id].isRandomRate); const globalVolume = useSoundStore(state => state.globalVolume); const adjustedVolume = useMemo( () => volume * globalVolume, [volume, globalVolume], ); + const actualPlaybackRate = useMemo( + () => speed * rate, + [speed, rate], + ); const isLoading = useLoadingStore(state => state.loaders[src]); - const sound = useSound(src, { loop: true, volume: adjustedVolume }); + const sound = useSound(src, { loop: true, volume: adjustedVolume, speed: actualPlaybackRate }); useEffect(() => { if (locked) return; @@ -60,6 +72,51 @@ export const Sound = forwardRef(function Sound( else if (hidden && !isSelected) unselectHidden(label); }, [label, isSelected, hidden, selectHidden, unselectHidden]); + // 改进的随机逻辑 - 每次只随机调整一个参数,频率为1分钟 + useEffect(() => { + const hasAnyRandom = isRandomSpeed || isRandomVolume || isRandomRate; + if (!hasAnyRandom || !isSelected || !isPlaying) return; + + const interval = setInterval(() => { + // 获取当前启用的随机选项列表 + const randomOptions = []; + if (isRandomSpeed) randomOptions.push('speed'); + if (isRandomRate) randomOptions.push('rate'); + if (isRandomVolume) randomOptions.push('volume'); + + if (randomOptions.length === 0) return; + + // 随机选择一个要调整的参数 + const selectedOption = randomOptions[Math.floor(Math.random() * randomOptions.length)]; + + switch (selectedOption) { + case 'speed': { + const baseSpeed = 1.0; + const randomSpeed = Math.random() * 0.5 + baseSpeed - 0.25; // baseSpeed ± 0.25 + const clampedSpeed = Math.max(0.5, Math.min(2.0, randomSpeed)); + setSpeed(id, clampedSpeed); + break; + } + case 'rate': { + const baseRate = 1.0; + const randomRate = Math.random() * 0.5 + baseRate - 0.25; // baseRate ± 0.25 + const clampedRate = Math.max(0.5, Math.min(2.0, randomRate)); + setRate(id, clampedRate); + break; + } + case 'volume': { + const baseVolume = 0.5; + const randomVolume = Math.random() * 0.4 + baseVolume * 0.3; // 30% - 70% 范围 + const clampedVolume = Math.max(0.0, Math.min(1.0, randomVolume)); + setVolume(id, clampedVolume); + break; + } + } + }, 60000 + Math.random() * 30000); // 每 60-90 秒更新一次,大约1分钟 + + return () => clearInterval(interval); + }, [isRandomSpeed, isRandomVolume, isRandomRate, isSelected, isPlaying, id, setSpeed, setRate, setVolume]); + const select = useCallback(() => { if (locked) return; selectSound(id); @@ -70,7 +127,17 @@ export const Sound = forwardRef(function Sound( if (locked) return; unselectSound(id); setVolume(id, 0.5); - }, [unselectSound, setVolume, id, locked]); + setSpeed(id, 1.0); + setRate(id, 1.0); + + // 确保所有随机模式都被重置 + const { toggleRandomSpeed, toggleRandomVolume, toggleRandomRate } = useSoundStore.getState(); + const sound = useSoundStore.getState().sounds[id]; + + if (sound?.isRandomSpeed) toggleRandomSpeed(id); + if (sound?.isRandomVolume) toggleRandomVolume(id); + if (sound?.isRandomRate) toggleRandomRate(id); + }, [unselectSound, setVolume, setSpeed, setRate, id, locked]); const toggle = useCallback(() => { if (locked) return; @@ -100,7 +167,10 @@ export const Sound = forwardRef(function Sound( onClick={handleClick} onKeyDown={handleKeyDown} > - +
+ + +
{isLoading ? (