mirror of
https://github.com/remvze/moodist.git
synced 2025-12-18 09:24:14 +00:00
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:
parent
9b532da501
commit
b973c7bf61
19 changed files with 852 additions and 91 deletions
95
README.en.md
Normal file
95
README.en.md
Normal 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
204
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
src/components/sounds/sound/random-speed/index.ts
Normal file
1
src/components/sounds/sound/random-speed/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { RandomSpeed } from './random-speed';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
89
src/components/sounds/sound/random-speed/random-speed.tsx
Normal file
89
src/components/sounds/sound/random-speed/random-speed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
1
src/components/theme-toggle/index.ts
Normal file
1
src/components/theme-toggle/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ThemeToggle } from './theme-toggle';
|
||||
46
src/components/theme-toggle/theme-toggle.module.css
Normal file
46
src/components/theme-toggle/theme-toggle.module.css
Normal 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);
|
||||
}
|
||||
107
src/components/theme-toggle/theme-toggle.tsx
Normal file
107
src/components/theme-toggle/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
export function useTranslatedSounds() {
|
||||
const { t } = useTranslation();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue