feat: 实现完整的昼夜主题系统和优化随机音频功能

## 主要功能更新

### 🎨 优化昼夜模式主题系统
- 参考现代设计标准(GitHub、VSCode)重新设计颜色方案
- 明亮主题:纯白背景 + 深灰文字,提供高对比度阅读体验
- 暗色主题:深蓝灰背景 + 高亮白色,护眼且现代
- 全面适配:所有组件背景色、边框色、前景色都跟随主题切换

### 🎲 改进随机音频功能
- 智能单参数随机:每次只随机调整一个参数(速度/音调/音量)
- 合理变化频率:调整为60-90秒,避免频繁变化影响体验
- 精确范围控制:
  - 速度和音调:默认值 ±0.25 范围内随机
  - 音量:30%-70% 范围内随机

### 📚 完善文档系统
- 创建英文版 README (README.en.md)
- 完善中文版 README,包含:
  - 详细的使用说明和操作指南
  - 完整的 Docker 部署教程
  - 生产环境配置指南
  - 在线体验地址:https://calm.zlext.com

### 🔧 技术改进
- 新增完整的主题切换组件 (ThemeToggle)
- 优化随机音频控制组件 (RandomSpeed)
- 改进声音控制组件的样式和交互
- 更新所有组件样式以支持主题变量

## 版本信息
- 版本升级:v2.1.0 → v2.2.0
- 新增功能:昼夜主题、智能随机、完整文档
- 向后兼容:完全兼容现有配置和数据
This commit is contained in:
zl 2025-11-17 11:03:14 +08:00
parent 9b532da501
commit b973c7bf61
19 changed files with 852 additions and 91 deletions

95
README.en.md Normal file
View file

@ -0,0 +1,95 @@
## 🌍 Language / 语言
**[English](README.en.md)** | **[简体中文](README.md)**
---
<div align="center">
<img src="/assets/banner.png" alt="Moodist Logo Banner" />
<h2>Moodist 🌲</h2>
<p>Ambient sounds for focus and calm.</p>
<a href="https://moodist.mvze.net">Visit <strong>Moodist</strong></a> | <a href="https://buymeacoffee.com/remvze">Buy Me a Coffee</a>
</div>
## 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/)

204
README.md
View file

@ -1,41 +1,157 @@
## 🌍 Language / 语言
**[English](README.md)** | **[简体中文](README.zh-CN.md)**
**[English](README.en.md)** | **[简体中文](README.md)**
---
<div align="center">
<img src="/assets/banner.png" alt="Moodist Logo Banner" />
<h2>Moodist 🌲</h2>
<p>Ambient sounds for focus and calm.</p>
<a href="https://moodist.mvze.net">Visit <strong>Moodist</strong></a> | <a href="https://buymeacoffee.com/remvze">Buy Me a Coffee</a>
<p>环境音工具,助你专注与平静</p>
<a href="https://moodist.mvze.net">访问 <strong>Moodist</strong></a> | <a href="https://calm.zlext.com/">在线体验地址</a> | <a href="https://buymeacoffee.com/remvze">支持开发者</a>
</div>
## 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.

View file

@ -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 (
<SnackbarProvider>
<StoreConsumer>
<ThemeToggle />
<MediaControls />
<Container>
<div id="app" />

View file

@ -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);
}
}

View file

@ -0,0 +1 @@
export { RandomSpeed } from './random-speed';

View file

@ -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);
}
}

View file

@ -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 (
<AnimatePresence initial={false} mode="wait">
<button
className={cn(styles.randomSpeedButton, hasAnyRandom && styles.isRandomSpeed)}
aria-label={
hasAnyRandom
? `Disable Random Effects for ${label} Sound`
: `Enable Random Effects for ${label} Sound`
}
onKeyDown={handleKeyDown}
onClick={e => {
e.stopPropagation();
handleToggle();
}}
>
<motion.span
animate="show"
aria-hidden="true"
exit="hidden"
initial="hidden"
key={hasAnyRandom ? `${id}-is-random` : `${id}-not-random`}
variants={variants}
>
<FiShuffle />
</motion.span>
</button>
</AnimatePresence>
);
}

View file

@ -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;
}
}

View file

@ -1,3 +1,4 @@
import { FaVolumeUp, FaTachometerAlt, FaMusic } from 'react-icons/fa/index';
import { useSoundStore } from '@/stores/sound';
import { useTranslation } from '@/hooks/useTranslation';
@ -11,11 +12,18 @@ 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 (
<div className={styles.controlsContainer}>
<div className={styles.volumeContainer}>
<FaVolumeUp className={styles.volumeIcon} />
<input
aria-label={`${label} ${t('volume').toLowerCase()}`}
autoComplete="off"
@ -30,5 +38,43 @@ export function Range({ id, label }: RangeProps) {
!locked && isSelected && setVolume(id, Number(e.target.value) / 100)
}
/>
</div>
<div className={styles.speedContainer}>
<FaTachometerAlt className={styles.speedIcon} />
<input
aria-label={`${label} speed`}
autoComplete="off"
className={styles.range}
disabled={!isSelected}
max={200}
min={50}
step={10}
type="range"
value={speed * 100}
onClick={e => e.stopPropagation()}
onChange={e =>
!locked && isSelected && setSpeed(id, Number(e.target.value) / 100)
}
/>
</div>
<div className={styles.rateContainer}>
<FaMusic className={styles.rateIcon} />
<input
aria-label={`${label} rate`}
autoComplete="off"
className={styles.range}
disabled={!isSelected}
max={200}
min={50}
step={10}
type="range"
value={rate * 100}
onClick={e => e.stopPropagation()}
onChange={e =>
!locked && isSelected && setRate(id, Number(e.target.value) / 100)
}
/>
</div>
</div>
);
}

View file

@ -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);

View file

@ -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<HTMLDivElement, SoundProps>(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<HTMLDivElement, SoundProps>(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<HTMLDivElement, SoundProps>(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<HTMLDivElement, SoundProps>(function Sound(
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<div className={styles.controlsContainer}>
<Favorite id={id} label={label} />
<RandomSpeed id={id} label={label} baseSpeed={speed} baseRate={rate} baseVolume={volume} />
</div>
<div className={styles.icon}>
{isLoading ? (
<span aria-hidden="true" className={styles.spinner}>

View file

@ -0,0 +1 @@
export { ThemeToggle } from './theme-toggle';

View file

@ -0,0 +1,46 @@
.themeToggle {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--color-foreground, #1e293b);
transition: all 0.2s ease;
font-size: 18px;
line-height: 1;
width: 40px;
height: 40px;
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
background: var(--bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #cbd5e1);
}
.themeToggle:hover {
background: var(--bg-tertiary, #e2e8f0);
transform: scale(1.05);
}
.themeToggle:focus {
outline: 2px solid var(--color-foreground, #1e293b);
outline-offset: 2px;
}
.themeToggle:active {
transform: scale(0.95);
}
/* 暗色主题下的特殊样式 */
:global(.dark-theme) .themeToggle {
background: var(--bg-secondary, #27272a);
border: 1px solid var(--color-border, #52525b);
}
:global(.dark-theme) .themeToggle:hover {
background: var(--bg-tertiary, #3f3f46);
}

View file

@ -0,0 +1,107 @@
import { useState, useEffect } from 'react';
import { FaSun, FaMoon } from 'react-icons/fa/index';
import { AnimatePresence, motion } from 'motion/react';
import styles from './theme-toggle.module.css';
import { fade } from '@/lib/motion';
export function ThemeToggle() {
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(false);
useEffect(() => {
// 从 localStorage 读取保存的主题,或使用系统偏好
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialDarkTheme = savedTheme ? savedTheme === 'dark' : systemPrefersDark;
setIsDarkTheme(initialDarkTheme);
applyTheme(initialDarkTheme);
}, []);
const applyTheme = (isDark: boolean) => {
const root = document.documentElement;
const body = document.body;
if (isDark) {
root.classList.add('dark-theme');
// 暗色主题 - 参考GitHub、VSCode等现代应用的深色主题
root.style.setProperty('--bg-primary', '#0d1117'); // 主背景 - 深蓝灰色类似GitHub
root.style.setProperty('--bg-secondary', '#161b22'); // 次要背景 - 稍浅的深蓝灰
root.style.setProperty('--bg-tertiary', '#21262d'); // 第三背景 - 中深蓝灰
root.style.setProperty('--bg-quaternary', '#30363d'); // 第四背景 - 蓝灰色
// 前景色 - 暗色主题使用高对比度的浅色文字
root.style.setProperty('--color-foreground', '#f0f6fc'); // 主前景色 - 高亮白色
root.style.setProperty('--color-foreground-subtle', '#8b949e'); // 次要前景色 - 柔和灰色
root.style.setProperty('--color-foreground-subtler', '#6e7681'); // 更次要前景色 - 中灰色
root.style.setProperty('--color-muted', '#484f58'); // 静音色 - 深灰色
root.style.setProperty('--color-border', '#30363d'); // 边框色 - 蓝灰色
// 组件特定背景
root.style.setProperty('--component-bg', '#161b22'); // 组件背景
root.style.setProperty('--component-hover', '#21262d'); // 组件悬停背景
root.style.setProperty('--component-active', '#30363d'); // 组件激活背景
root.style.setProperty('--modal-bg', '#0d1117'); // 模态框背景
root.style.setProperty('--input-bg', '#0d1117'); // 输入框背景
// 直接设置body背景为深色
body.style.backgroundColor = '#0d1117';
} else {
root.classList.remove('dark-theme');
// 明亮主题 - 参考现代简洁设计,更干净的白色系
root.style.setProperty('--bg-primary', '#ffffff'); // 主背景 - 纯白色
root.style.setProperty('--bg-secondary', '#fafbfc'); // 次要背景 - 极浅灰白
root.style.setProperty('--bg-tertiary', '#f6f8fa'); // 第三背景 - 浅灰白
root.style.setProperty('--bg-quaternary', '#e1e4e8'); // 第四背景 - 浅灰色
// 前景色 - 明亮主题使用深色文字,确保良好对比度
root.style.setProperty('--color-foreground', '#24292f'); // 主前景色 - 深灰GitHub风格
root.style.setProperty('--color-foreground-subtle', '#656d76'); // 次要前景色 - 中灰色
root.style.setProperty('--color-foreground-subtler', '#57606a'); // 更次要前景色 - 深灰色
root.style.setProperty('--color-muted', '#8b949e'); // 静音色 - 灰色
root.style.setProperty('--color-border', '#d0d7de'); // 边框色 - 柔和浅灰
// 组件特定背景
root.style.setProperty('--component-bg', '#ffffff'); // 组件背景
root.style.setProperty('--component-hover', '#f3f4f6'); // 组件悬停背景
root.style.setProperty('--component-active', '#e5e7eb'); // 组件激活背景
root.style.setProperty('--modal-bg', '#ffffff'); // 模态框背景
root.style.setProperty('--input-bg', '#ffffff'); // 输入框背景
// 直接设置body背景为白色
body.style.backgroundColor = '#ffffff';
}
};
const toggleTheme = () => {
const newTheme = !isDarkTheme;
setIsDarkTheme(newTheme);
applyTheme(newTheme);
localStorage.setItem('theme', newTheme ? 'dark' : 'light');
};
const variants = fade();
return (
<button
className={styles.themeToggle}
onClick={toggleTheme}
aria-label={isDarkTheme ? 'Switch to light mode' : 'Switch to dark mode'}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
animate="show"
aria-hidden="true"
exit="hidden"
initial="hidden"
key={isDarkTheme ? 'moon' : 'sun'}
variants={variants}
>
{isDarkTheme ? <FaMoon /> : <FaSun />}
</motion.span>
</AnimatePresence>
</button>
);
}

View file

@ -1,4 +1,4 @@
import { useTranslation } from '@/hooks/use-translation';
import { useTranslation } from '@/hooks/useTranslation';
export function useTranslatedSounds() {
const { t } = useTranslation();

View file

@ -17,6 +17,7 @@ import { FADE_OUT } from '@/constants/events';
* @param {Object} [options] - Options for sound playback.
* @param {boolean} [options.loop=false] - Whether the sound should loop.
* @param {number} [options.volume=0.5] - The initial volume of the sound, ranging from 0.0 to 1.0.
* @param {number} [options.speed=1.0] - The initial playback speed of the sound, ranging from 0.5 to 2.0.
* @returns {{ play: () => void, stop: () => void, pause: () => void, fadeOut: (duration: number) => void, isLoading: boolean }} An object containing control functions for the sound:
* - play: Function to play the sound.
* - stop: Function to stop the sound.
@ -26,7 +27,7 @@ import { FADE_OUT } from '@/constants/events';
*/
export function useSound(
src: string,
options: { loop?: boolean; preload?: boolean; volume?: number } = {},
options: { loop?: boolean; preload?: boolean; volume?: number; speed?: number } = {},
html5: boolean = false,
) {
const [hasLoaded, setHasLoaded] = useState(false);
@ -62,6 +63,10 @@ export function useSound(
if (sound) sound.volume(options.volume ?? 0.5);
}, [sound, options.volume]);
useEffect(() => {
if (sound) sound.rate(options.speed ?? 1.0);
}, [sound, options.speed]);
const play = useCallback(
(cb?: () => void) => {
if (sound) {

View file

@ -13,6 +13,12 @@ export interface SoundActions {
select: (id: string) => void;
setGlobalVolume: (volume: number) => void;
setVolume: (id: string, volume: number) => void;
setSpeed: (id: string, speed: number) => void;
setRate: (id: string, rate: number) => void;
toggleRandomSpeed: (id: string) => void;
toggleRandomVolume: (id: string) => void;
toggleRandomRate: (id: string) => void;
toggleAllRandom: (id: string) => void;
shuffle: () => void;
toggleFavorite: (id: string) => void;
togglePlay: () => void;
@ -88,6 +94,78 @@ export const createActions: StateCreator<
});
},
setSpeed(id, speed) {
set({
sounds: {
...get().sounds,
[id]: { ...get().sounds[id], speed },
},
});
},
setRate(id, rate) {
set({
sounds: {
...get().sounds,
[id]: { ...get().sounds[id], rate },
},
});
},
toggleRandomSpeed(id) {
const currentSound = get().sounds[id];
const isRandomSpeed = !currentSound.isRandomSpeed;
set({
sounds: {
...get().sounds,
[id]: { ...currentSound, isRandomSpeed },
},
});
},
toggleRandomVolume(id) {
const currentSound = get().sounds[id];
const isRandomVolume = !currentSound.isRandomVolume;
set({
sounds: {
...get().sounds,
[id]: { ...currentSound, isRandomVolume },
},
});
},
toggleRandomRate(id) {
const currentSound = get().sounds[id];
const isRandomRate = !currentSound.isRandomRate;
set({
sounds: {
...get().sounds,
[id]: { ...currentSound, isRandomRate },
},
});
},
toggleAllRandom(id) {
const currentSound = get().sounds[id];
const hasAnyRandom = currentSound.isRandomSpeed || currentSound.isRandomVolume || currentSound.isRandomRate;
const newState = !hasAnyRandom;
set({
sounds: {
...get().sounds,
[id]: {
...currentSound,
isRandomSpeed: newState,
isRandomVolume: newState,
isRandomRate: newState
},
},
});
},
shuffle() {
const sounds = get().sounds;
const ids = Object.keys(sounds);
@ -95,6 +173,11 @@ export const createActions: StateCreator<
ids.forEach(id => {
sounds[id].isSelected = false;
sounds[id].volume = 0.5;
sounds[id].speed = 1.0;
sounds[id].rate = 1.0;
sounds[id].isRandomSpeed = false;
sounds[id].isRandomVolume = false;
sounds[id].isRandomRate = false;
});
const randomIDs = pickMany(ids, 4);
@ -154,6 +237,11 @@ export const createActions: StateCreator<
ids.forEach(id => {
sounds[id].isSelected = false;
sounds[id].volume = 0.5;
sounds[id].speed = 1.0;
sounds[id].rate = 1.0;
sounds[id].isRandomSpeed = false;
sounds[id].isRandomVolume = false;
sounds[id].isRandomRate = false;
});
set({ sounds });

View file

@ -12,6 +12,11 @@ export interface SoundState {
isFavorite: boolean;
isSelected: boolean;
volume: number;
speed: number;
rate: number;
isRandomSpeed: boolean;
isRandomVolume: boolean;
isRandomRate: boolean;
};
} | null;
isPlaying: boolean;
@ -22,6 +27,11 @@ export interface SoundState {
isFavorite: boolean;
isSelected: boolean;
volume: number;
speed: number;
rate: number;
isRandomSpeed: boolean;
isRandomVolume: boolean;
isRandomRate: boolean;
};
};
}
@ -63,6 +73,11 @@ export const createState: StateCreator<
isFavorite: false,
isSelected: false,
volume: 0.5,
speed: 1.0,
rate: 1.0,
isRandomSpeed: false,
isRandomVolume: false,
isRandomRate: false,
};
});
});

View file

@ -15,10 +15,11 @@ body {
font-family: var(--font-body);
font-size: var(--font-base);
color: var(--color-foreground);
background-color: var(--color-neutral-50);
background-color: var(--bg-primary);
/* Workaround for modal and scrollbar layout shifts */
width: 100vw;
overflow-x: hidden;
transition: background-color 0.3s ease;
}
::selection {