mirror of
https://github.com/zhouhua/obsidian-white-noise.git
synced 2026-07-22 05:43:36 +00:00
init
This commit is contained in:
parent
3b44e71606
commit
8b76a41186
100 changed files with 10771 additions and 1 deletions
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: zhouhua
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
41
.github/workflows/release.yml
vendored
Normal file
41
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
run_install: true
|
||||
|
||||
- name: Build plugin
|
||||
run: pnpm build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
tag="${tag#v}"
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
main.js manifest.json styles.css
|
||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Production
|
||||
build
|
||||
dist
|
||||
storybook-static
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Cache
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
main.js
|
||||
styles.css
|
||||
6
.typesafe-i18n.json
Normal file
6
.typesafe-i18n.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"baseLocale": "zh",
|
||||
"adapters": [],
|
||||
"esmImports": true,
|
||||
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json"
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Zhou Hua
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
28
README-zh.md
Normal file
28
README-zh.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Obsidian 白噪音插件
|
||||
|
||||
[English Version](README.md)
|
||||
|
||||
为Obsidian 添加白噪音功能,旨在帮助用户更专注地、更平静地进入工作状态。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **白噪音**:包含雨声、咖啡厅噪音等数十种声音
|
||||
- **音量控制**:单独控制白噪音音量,也许可以与你的音乐共存
|
||||
- **计时器功能**:内置一个简单的番茄钟功能,可以与白噪音联动
|
||||
|
||||
## 安装方法
|
||||
|
||||
1. 打开Obsidian,导航至**设置**
|
||||
2. 进入**第三方插件**并关闭**安全模式**
|
||||
3. 点击**浏览**并搜索"white noise"
|
||||
4. 点击**安装**,然后**启用**插件
|
||||
|
||||
## 致谢
|
||||
|
||||
本插件使用了来自[Moodist](https://github.com/remvze/moodist)项目的声音素材。但为了更好地适用于 obsidian 环境,对一些声音素材进行了删改:
|
||||
- 删除了部分可能让人紧张的音效(如雷声等);
|
||||
- 删除了少量体积过大的音频,以减小插件的体积;
|
||||
- 对音频进行了再次压缩,修改为动态码率,合并双声道为联合立体声,仅保留 40Hz ~ 15000Hz 的声音;
|
||||
- 删除基础噪音的音频,改成程序生成的方式动态生成。
|
||||
|
||||
考虑访问 [Moodist 项目](https://github.com/remvze/moodist) 以了解更多信息。
|
||||
29
README.md
29
README.md
|
|
@ -1 +1,28 @@
|
|||
# obsidian-white-noise
|
||||
# Obsidian White Noise Plugin
|
||||
|
||||
[中文版本](README-zh.md)
|
||||
|
||||
A white noise plugin for Obsidian designed to help users focus better and enter a calmer working state.
|
||||
|
||||
## Features
|
||||
|
||||
- **White Noise**: Includes dozens of sounds such as rain, coffee shop noise, and more
|
||||
- **Volume Control**: Independently control the white noise volume, which can coexist with your music
|
||||
- **Timer Function**: Built-in simple pomodoro timer that works in conjunction with white noise
|
||||
|
||||
## Installation
|
||||
|
||||
1. Open Obsidian and navigate to **Settings**
|
||||
2. Go to **Community plugins** and disable **Safe mode**
|
||||
3. Click on **Browse** and search for "White Noise"
|
||||
4. Click **Install**, then **Enable** the plugin
|
||||
|
||||
## Credits
|
||||
|
||||
This plugin uses sound materials from the [Moodist](https://github.com/remvze/moodist) project. To better adapt to the Obsidian environment, some sound materials have been modified:
|
||||
- Removed some potentially anxiety-inducing sound effects (such as thunder)
|
||||
- Removed several oversized audio files to reduce the plugin size
|
||||
- Further compressed the audio, modified to variable bit rate, merged dual channels into joint stereo, and preserved only sounds between 40Hz ~ 15000Hz
|
||||
- Removed base noise audio files, replacing them with dynamically generated noise
|
||||
|
||||
Consider visiting the [Moodist project](https://github.com/remvze/moodist) to learn more.
|
||||
|
|
|
|||
17
components.json
Normal file
17
components.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": "wn-"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "./src/components",
|
||||
"utils": "./src/lib/utils"
|
||||
}
|
||||
}
|
||||
68
esbuild.config.mjs
Normal file
68
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import process from 'node:process';
|
||||
import builtins from 'builtin-modules';
|
||||
import esbuild from 'esbuild';
|
||||
|
||||
const banner
|
||||
= `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = process.argv[2] === 'production';
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
bundle: true,
|
||||
entryNames: '[dir]/[name]',
|
||||
entryPoints: [
|
||||
{ in: 'src/main.ts', out: 'main' },
|
||||
],
|
||||
external: [
|
||||
'obsidian',
|
||||
'electron',
|
||||
'@codemirror/autocomplete',
|
||||
'@codemirror/collab',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/language',
|
||||
'@codemirror/lint',
|
||||
'@codemirror/search',
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@lezer/common',
|
||||
'@lezer/highlight',
|
||||
'@lezer/lr',
|
||||
...builtins,
|
||||
],
|
||||
format: 'cjs',
|
||||
loader: {
|
||||
'.css': 'css',
|
||||
'.mp3': 'dataurl',
|
||||
'.wav': 'dataurl',
|
||||
'.svg': 'text',
|
||||
'.tsx': 'tsx',
|
||||
'.jsx': 'jsx',
|
||||
},
|
||||
logLevel: 'info',
|
||||
// outfile: "main.js",
|
||||
minify: false,
|
||||
outdir: '.',
|
||||
outExtension: { '.css': '.css' },
|
||||
platform: 'browser',
|
||||
sourcemap: prod ? false : 'inline',
|
||||
target: 'es2018',
|
||||
treeShaking: true,
|
||||
jsx: 'automatic',
|
||||
jsxFactory: 'React.createElement',
|
||||
jsxFragment: 'React.Fragment',
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
}
|
||||
else {
|
||||
await context.watch();
|
||||
}
|
||||
3
eslint.config.js
Normal file
3
eslint.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import baseConfig from '@waveform/inf/eslint';
|
||||
|
||||
export default baseConfig;
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "obsidian-white-noise",
|
||||
"name": "White Noise",
|
||||
"version": "0.0.1",
|
||||
"minAppVersion": "1.7.7",
|
||||
"description": "Play white noise in Obsidian to help you focus on your work",
|
||||
"author": "Zhou Hua",
|
||||
"authorUrl": "https://zhouhua.site",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
63
package.json
Normal file
63
package.json
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"name": "obsidian-white-noise",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"description": "Play white noise in Obsidian to help you focus on your work",
|
||||
"author": "zhouhua",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"plugin",
|
||||
"audio",
|
||||
"white noise"
|
||||
],
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build:css": "tailwindcss -i ./src/main.css -o styles.css",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && npm run build:css",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"convert-audio": "bash scripts/convert_audio_format.sh",
|
||||
"typesafe-i18n": "typesafe-i18n"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@waveform-audio/inf": "^0.0.1",
|
||||
"builtin-modules": "4.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"esbuild": "0.25.0",
|
||||
"obsidian": "latest",
|
||||
"obsidian-typings": "^2.8.1",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.476.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"typesafe-i18n": "^5.26.2"
|
||||
}
|
||||
}
|
||||
7047
pnpm-lock.yaml
Normal file
7047
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
88
scripts/convert_audio_format.sh
Normal file
88
scripts/convert_audio_format.sh
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/bin/bash
|
||||
|
||||
# 音频批量转换脚本
|
||||
# 将sounds目录下所有MP3文件转换为比特率128k,采样率24000Hz的格式
|
||||
|
||||
# 设置变量
|
||||
SOURCE_DIR="./sounds"
|
||||
TARGET_BITRATE="128k"
|
||||
TARGET_SAMPLERATE="24000"
|
||||
TEMP_DIR="./temp_audio"
|
||||
|
||||
# 确保ffmpeg已安装
|
||||
if ! command -v ffmpeg &> /dev/null; then
|
||||
echo "错误: 未找到ffmpeg命令。请先安装ffmpeg。"
|
||||
echo "可以使用 'brew install ffmpeg' (Mac) 或 'apt-get install ffmpeg' (Linux) 安装。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建临时目录
|
||||
mkdir -p "$TEMP_DIR"
|
||||
|
||||
echo "开始转换音频文件..."
|
||||
echo "目标比特率: $TARGET_BITRATE"
|
||||
echo "目标采样率: $TARGET_SAMPLERATE Hz"
|
||||
|
||||
# 统一处理文件路径
|
||||
cd $(dirname "$SOURCE_DIR")
|
||||
SOURCE_NAME=$(basename "$SOURCE_DIR")
|
||||
|
||||
# 查找所有MP3文件并处理
|
||||
find "./$SOURCE_NAME" -type f -name "*.mp3" | while read -r file; do
|
||||
# 确保文件路径以 ./sounds 开头
|
||||
normalized_file=${file#/} # 移除可能的开头斜杠
|
||||
normalized_file=${normalized_file#./} # 移除可能的开头./
|
||||
normalized_file="./$normalized_file" # 添加./前缀
|
||||
|
||||
# 获取相对路径
|
||||
rel_path="${normalized_file#./$SOURCE_NAME/}"
|
||||
|
||||
# 创建目标文件的目录结构
|
||||
target_dir="$TEMP_DIR/$(dirname "$rel_path")"
|
||||
mkdir -p "$target_dir"
|
||||
|
||||
# 设置输出文件路径
|
||||
output_file="$TEMP_DIR/$rel_path"
|
||||
|
||||
echo "处理: $normalized_file"
|
||||
|
||||
# 使用ffmpeg转换文件,确保文件路径用引号包围
|
||||
ffmpeg -i "$normalized_file" -b:a $TARGET_BITRATE -ar $TARGET_SAMPLERATE -q:a 5 -map_metadata -1 -joint_stereo 1 -af "highpass=f=40, lowpass=f=15000" "$output_file"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ 成功转换: $rel_path"
|
||||
else
|
||||
echo "✗ 转换失败: $rel_path"
|
||||
fi
|
||||
done
|
||||
|
||||
# 回到原来的目录
|
||||
cd - > /dev/null
|
||||
|
||||
echo "所有文件转换完成。"
|
||||
echo "转换后的文件保存在临时目录: $TEMP_DIR"
|
||||
echo
|
||||
|
||||
# 询问是否替换原文件
|
||||
read -p "是否用转换后的文件替换原文件?(y/n): " replace_answer
|
||||
if [[ "$replace_answer" =~ ^[Yy]$ ]]; then
|
||||
echo "正在替换原文件..."
|
||||
|
||||
find "$TEMP_DIR" -type f -name "*.mp3" | while read -r file; do
|
||||
# 获取相对于临时目录的路径
|
||||
rel_path="${file#$TEMP_DIR/}"
|
||||
original_file="$SOURCE_DIR/$rel_path"
|
||||
|
||||
# 替换原文件
|
||||
mv "$file" "$original_file"
|
||||
echo "已替换: $rel_path"
|
||||
done
|
||||
|
||||
# 清理临时目录
|
||||
rm -rf "$TEMP_DIR"
|
||||
echo "原文件已替换完成!"
|
||||
else
|
||||
echo "转换后的文件保留在临时目录: $TEMP_DIR"
|
||||
echo "您可以手动将其移动到所需位置。"
|
||||
fi
|
||||
|
||||
echo "音频转换过程已完成。"
|
||||
BIN
sounds/alarm.mp3
Normal file
BIN
sounds/alarm.mp3
Normal file
Binary file not shown.
BIN
sounds/animals/birds.mp3
Normal file
BIN
sounds/animals/birds.mp3
Normal file
Binary file not shown.
BIN
sounds/animals/crickets.mp3
Normal file
BIN
sounds/animals/crickets.mp3
Normal file
Binary file not shown.
BIN
sounds/animals/crows.mp3
Normal file
BIN
sounds/animals/crows.mp3
Normal file
Binary file not shown.
BIN
sounds/animals/dog-barking.mp3
Normal file
BIN
sounds/animals/dog-barking.mp3
Normal file
Binary file not shown.
BIN
sounds/animals/horse-galopp.mp3
Normal file
BIN
sounds/animals/horse-galopp.mp3
Normal file
Binary file not shown.
BIN
sounds/animals/owl.mp3
Normal file
BIN
sounds/animals/owl.mp3
Normal file
Binary file not shown.
BIN
sounds/animals/seagulls.mp3
Normal file
BIN
sounds/animals/seagulls.mp3
Normal file
Binary file not shown.
BIN
sounds/animals/whale.mp3
Normal file
BIN
sounds/animals/whale.mp3
Normal file
Binary file not shown.
BIN
sounds/nature/campfire.mp3
Normal file
BIN
sounds/nature/campfire.mp3
Normal file
Binary file not shown.
BIN
sounds/nature/droplets.mp3
Normal file
BIN
sounds/nature/droplets.mp3
Normal file
Binary file not shown.
BIN
sounds/nature/howling-wind.mp3
Normal file
BIN
sounds/nature/howling-wind.mp3
Normal file
Binary file not shown.
BIN
sounds/nature/river.mp3
Normal file
BIN
sounds/nature/river.mp3
Normal file
Binary file not shown.
BIN
sounds/nature/walk-in-snow.mp3
Normal file
BIN
sounds/nature/walk-in-snow.mp3
Normal file
Binary file not shown.
BIN
sounds/nature/walk-on-leaves.mp3
Normal file
BIN
sounds/nature/walk-on-leaves.mp3
Normal file
Binary file not shown.
BIN
sounds/nature/waterfall.mp3
Normal file
BIN
sounds/nature/waterfall.mp3
Normal file
Binary file not shown.
BIN
sounds/nature/waves.mp3
Normal file
BIN
sounds/nature/waves.mp3
Normal file
Binary file not shown.
BIN
sounds/noise/brown-noise.wav
Executable file
BIN
sounds/noise/brown-noise.wav
Executable file
Binary file not shown.
BIN
sounds/noise/pink-noise.wav
Executable file
BIN
sounds/noise/pink-noise.wav
Executable file
Binary file not shown.
BIN
sounds/noise/white-noise.wav
Executable file
BIN
sounds/noise/white-noise.wav
Executable file
Binary file not shown.
BIN
sounds/places/cafe.mp3
Normal file
BIN
sounds/places/cafe.mp3
Normal file
Binary file not shown.
BIN
sounds/places/church.mp3
Normal file
BIN
sounds/places/church.mp3
Normal file
Binary file not shown.
BIN
sounds/places/construction-site.mp3
Normal file
BIN
sounds/places/construction-site.mp3
Normal file
Binary file not shown.
BIN
sounds/places/crowded-bar.mp3
Normal file
BIN
sounds/places/crowded-bar.mp3
Normal file
Binary file not shown.
BIN
sounds/places/laboratory.mp3
Normal file
BIN
sounds/places/laboratory.mp3
Normal file
Binary file not shown.
BIN
sounds/places/laundry-room.mp3
Normal file
BIN
sounds/places/laundry-room.mp3
Normal file
Binary file not shown.
BIN
sounds/places/night-village.mp3
Normal file
BIN
sounds/places/night-village.mp3
Normal file
Binary file not shown.
BIN
sounds/places/office.mp3
Normal file
BIN
sounds/places/office.mp3
Normal file
Binary file not shown.
BIN
sounds/places/subway-station.mp3
Normal file
BIN
sounds/places/subway-station.mp3
Normal file
Binary file not shown.
BIN
sounds/places/supermarket.mp3
Normal file
BIN
sounds/places/supermarket.mp3
Normal file
Binary file not shown.
BIN
sounds/places/underwater.mp3
Normal file
BIN
sounds/places/underwater.mp3
Normal file
Binary file not shown.
BIN
sounds/rain/heavy-rain.mp3
Normal file
BIN
sounds/rain/heavy-rain.mp3
Normal file
Binary file not shown.
BIN
sounds/rain/light-rain.mp3
Normal file
BIN
sounds/rain/light-rain.mp3
Normal file
Binary file not shown.
BIN
sounds/rain/rain-on-leaves.mp3
Normal file
BIN
sounds/rain/rain-on-leaves.mp3
Normal file
Binary file not shown.
BIN
sounds/rain/rain-on-umbrella.mp3
Normal file
BIN
sounds/rain/rain-on-umbrella.mp3
Normal file
Binary file not shown.
BIN
sounds/rain/rain-on-window.mp3
Normal file
BIN
sounds/rain/rain-on-window.mp3
Normal file
Binary file not shown.
BIN
sounds/things/boiling-water.mp3
Normal file
BIN
sounds/things/boiling-water.mp3
Normal file
Binary file not shown.
BIN
sounds/things/bubbles.mp3
Normal file
BIN
sounds/things/bubbles.mp3
Normal file
Binary file not shown.
BIN
sounds/things/ceiling-fan.mp3
Normal file
BIN
sounds/things/ceiling-fan.mp3
Normal file
Binary file not shown.
BIN
sounds/things/clock.mp3
Normal file
BIN
sounds/things/clock.mp3
Normal file
Binary file not shown.
BIN
sounds/things/dryer.mp3
Normal file
BIN
sounds/things/dryer.mp3
Normal file
Binary file not shown.
BIN
sounds/things/keyboard.mp3
Normal file
BIN
sounds/things/keyboard.mp3
Normal file
Binary file not shown.
BIN
sounds/things/paper.mp3
Normal file
BIN
sounds/things/paper.mp3
Normal file
Binary file not shown.
BIN
sounds/things/typewriter.mp3
Normal file
BIN
sounds/things/typewriter.mp3
Normal file
Binary file not shown.
BIN
sounds/things/washing-machine.mp3
Normal file
BIN
sounds/things/washing-machine.mp3
Normal file
Binary file not shown.
BIN
sounds/things/wind-chimes.mp3
Normal file
BIN
sounds/things/wind-chimes.mp3
Normal file
Binary file not shown.
BIN
sounds/urban/crowd.mp3
Normal file
BIN
sounds/urban/crowd.mp3
Normal file
Binary file not shown.
BIN
sounds/urban/fireworks.mp3
Normal file
BIN
sounds/urban/fireworks.mp3
Normal file
Binary file not shown.
BIN
sounds/urban/highway.mp3
Normal file
BIN
sounds/urban/highway.mp3
Normal file
Binary file not shown.
BIN
sounds/urban/road.mp3
Normal file
BIN
sounds/urban/road.mp3
Normal file
Binary file not shown.
BIN
sounds/urban/traffic.mp3
Normal file
BIN
sounds/urban/traffic.mp3
Normal file
Binary file not shown.
26
src/L.ts
Normal file
26
src/L.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { loadAllLocales } from './i18n/i18n-util.sync';
|
||||
import { i18n } from './i18n/i18n-util';
|
||||
import type { Locales } from './i18n/i18n-types';
|
||||
|
||||
loadAllLocales();
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
i18next: {
|
||||
language: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let locale: Locales = 'en';
|
||||
try {
|
||||
locale = (window.i18next.language || '').startsWith('zh') ? 'zh' : 'en';
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const L = i18n()[locale];
|
||||
|
||||
export default L;
|
||||
140
src/audio-assets.ts
Normal file
140
src/audio-assets.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// 这个文件负责导入所有音频文件
|
||||
|
||||
// 动物声音
|
||||
import birdsSrc from '../sounds/animals/birds.mp3';
|
||||
import cricketsSrc from '../sounds/animals/crickets.mp3';
|
||||
import crowsSrc from '../sounds/animals/crows.mp3';
|
||||
import dogBarkingSrc from '../sounds/animals/dog-barking.mp3';
|
||||
import horseGaloppSrc from '../sounds/animals/horse-galopp.mp3';
|
||||
import owlSrc from '../sounds/animals/owl.mp3';
|
||||
import seagullsSrc from '../sounds/animals/seagulls.mp3';
|
||||
import whaleSrc from '../sounds/animals/whale.mp3';
|
||||
|
||||
// 自然环境
|
||||
import campfireSrc from '../sounds/nature/campfire.mp3';
|
||||
import dropletsSrc from '../sounds/nature/droplets.mp3';
|
||||
import howlingWindSrc from '../sounds/nature/howling-wind.mp3';
|
||||
import riverSrc from '../sounds/nature/river.mp3';
|
||||
import walkInSnowSrc from '../sounds/nature/walk-in-snow.mp3';
|
||||
import walkOnLeavesSrc from '../sounds/nature/walk-on-leaves.mp3';
|
||||
import waterfallSrc from '../sounds/nature/waterfall.mp3';
|
||||
import wavesSrc from '../sounds/nature/waves.mp3';
|
||||
|
||||
// 基础噪音
|
||||
import brownNoiseSrc from '../sounds/noise/brown-noise.wav';
|
||||
import pinkNoiseSrc from '../sounds/noise/pink-noise.wav';
|
||||
import whiteNoiseSrc from '../sounds/noise/white-noise.wav';
|
||||
|
||||
// 场所环境
|
||||
import cafeSrc from '../sounds/places/cafe.mp3';
|
||||
import churchSrc from '../sounds/places/church.mp3';
|
||||
import constructionSiteSrc from '../sounds/places/construction-site.mp3';
|
||||
import crowdedBarSrc from '../sounds/places/crowded-bar.mp3';
|
||||
import laboratorySrc from '../sounds/places/laboratory.mp3';
|
||||
import laundryRoomSrc from '../sounds/places/laundry-room.mp3';
|
||||
import nightVillageSrc from '../sounds/places/night-village.mp3';
|
||||
import officeSrc from '../sounds/places/office.mp3';
|
||||
import subwayStationSrc from '../sounds/places/subway-station.mp3';
|
||||
import supermarketSrc from '../sounds/places/supermarket.mp3';
|
||||
import underwaterSrc from '../sounds/places/underwater.mp3';
|
||||
|
||||
// 雨声
|
||||
import heavyRainSrc from '../sounds/rain/heavy-rain.mp3';
|
||||
import lightRainSrc from '../sounds/rain/light-rain.mp3';
|
||||
import rainOnLeavesSrc from '../sounds/rain/rain-on-leaves.mp3';
|
||||
import rainOnUmbrellaSrc from '../sounds/rain/rain-on-umbrella.mp3';
|
||||
import rainOnWindowSrc from '../sounds/rain/rain-on-window.mp3';
|
||||
|
||||
// 物品声音
|
||||
import boilingWaterSrc from '../sounds/things/boiling-water.mp3';
|
||||
import bubblesSrc from '../sounds/things/bubbles.mp3';
|
||||
import ceilingFanSrc from '../sounds/things/ceiling-fan.mp3';
|
||||
import clockSrc from '../sounds/things/clock.mp3';
|
||||
import dryerSrc from '../sounds/things/dryer.mp3';
|
||||
import keyboardSrc from '../sounds/things/keyboard.mp3';
|
||||
import paperSrc from '../sounds/things/paper.mp3';
|
||||
import typewriterSrc from '../sounds/things/typewriter.mp3';
|
||||
import washingMachineSrc from '../sounds/things/washing-machine.mp3';
|
||||
import windChimesSrc from '../sounds/things/wind-chimes.mp3';
|
||||
|
||||
// 城市声音
|
||||
import crowdSrc from '../sounds/urban/crowd.mp3';
|
||||
import fireworksSrc from '../sounds/urban/fireworks.mp3';
|
||||
import highwaySrc from '../sounds/urban/highway.mp3';
|
||||
import roadSrc from '../sounds/urban/road.mp3';
|
||||
import trafficSrc from '../sounds/urban/traffic.mp3';
|
||||
|
||||
import alarmSrc from '../sounds/alarm.mp3';
|
||||
import { Sound } from './types';
|
||||
import L from './L';
|
||||
|
||||
export { alarmSrc };
|
||||
|
||||
// 音频资源列表
|
||||
// 定义所有音频的路径和元数据
|
||||
export const audioAssets: Sound[] = [
|
||||
// 动物声音
|
||||
{ id: 'animals/birds', name: L.sounds.birds(), src: birdsSrc, category: 'animals' },
|
||||
{ id: 'animals/crickets', name: L.sounds.crickets(), src: cricketsSrc, category: 'animals' },
|
||||
{ id: 'animals/crows', name: L.sounds.crows(), src: crowsSrc, category: 'animals' },
|
||||
{ id: 'animals/dog-barking', name: L.sounds.dogBarking(), src: dogBarkingSrc, category: 'animals' },
|
||||
{ id: 'animals/horse-galopp', name: L.sounds.horseGalopp(), src: horseGaloppSrc, category: 'animals' },
|
||||
{ id: 'animals/owl', name: L.sounds.owl(), src: owlSrc, category: 'animals' },
|
||||
{ id: 'animals/seagulls', name: L.sounds.seagulls(), src: seagullsSrc, category: 'animals' },
|
||||
{ id: 'animals/whale', name: L.sounds.whale(), src: whaleSrc, category: 'animals' },
|
||||
|
||||
// 自然环境
|
||||
{ id: 'nature/campfire', name: L.sounds.campfire(), src: campfireSrc, category: 'nature' },
|
||||
{ id: 'nature/droplets', name: L.sounds.droplets(), src: dropletsSrc, category: 'nature' },
|
||||
{ id: 'nature/howling-wind', name: L.sounds.howlingWind(), src: howlingWindSrc, category: 'nature' },
|
||||
{ id: 'nature/river', name: L.sounds.river(), src: riverSrc, category: 'nature' },
|
||||
{ id: 'nature/walk-in-snow', name: L.sounds.walkInSnow(), src: walkInSnowSrc, category: 'nature' },
|
||||
{ id: 'nature/walk-on-leaves', name: L.sounds.walkOnLeaves(), src: walkOnLeavesSrc, category: 'nature' },
|
||||
{ id: 'nature/waterfall', name: L.sounds.waterfall(), src: waterfallSrc, category: 'nature' },
|
||||
{ id: 'nature/waves', name: L.sounds.waves(), src: wavesSrc, category: 'nature' },
|
||||
|
||||
// 基础噪音
|
||||
{ id: 'noise/brown-noise', name: L.sounds.brownNoise(), src: brownNoiseSrc, category: 'noise' },
|
||||
{ id: 'noise/pink-noise', name: L.sounds.pinkNoise(), src: pinkNoiseSrc, category: 'noise' },
|
||||
{ id: 'noise/white-noise', name: L.sounds.whiteNoise(), src: whiteNoiseSrc, category: 'noise' },
|
||||
|
||||
// 场所环境
|
||||
{ id: 'places/cafe', name: L.sounds.cafe(), src: cafeSrc, category: 'places' },
|
||||
{ id: 'places/church', name: L.sounds.church(), src: churchSrc, category: 'places' },
|
||||
{ id: 'places/construction-site', name: L.sounds.constructionSite(), src: constructionSiteSrc, category: 'places' },
|
||||
{ id: 'places/crowded-bar', name: L.sounds.crowdedBar(), src: crowdedBarSrc, category: 'places' },
|
||||
{ id: 'places/laboratory', name: L.sounds.laboratory(), src: laboratorySrc, category: 'places' },
|
||||
{ id: 'places/laundry-room', name: '洗衣房', src: laundryRoomSrc, category: 'places' },
|
||||
{ id: 'places/night-village', name: L.sounds.nightVillage(), src: nightVillageSrc, category: 'places' },
|
||||
{ id: 'places/office', name: L.sounds.office(), src: officeSrc, category: 'places' },
|
||||
{ id: 'places/subway-station', name: L.sounds.subwayStation(), src: subwayStationSrc, category: 'places' },
|
||||
{ id: 'places/supermarket', name: L.sounds.supermarket(), src: supermarketSrc, category: 'places' },
|
||||
{ id: 'places/underwater', name: L.sounds.underwater(), src: underwaterSrc, category: 'places' },
|
||||
|
||||
// 雨声
|
||||
{ id: 'rain/heavy-rain', name: L.sounds.heavyRain(), src: heavyRainSrc, category: 'rain' },
|
||||
{ id: 'rain/light-rain', name: L.sounds.lightRain(), src: lightRainSrc, category: 'rain' },
|
||||
{ id: 'rain/rain-on-leaves', name: L.sounds.rainOnLeaves(), src: rainOnLeavesSrc, category: 'rain' },
|
||||
{ id: 'rain/rain-on-umbrella', name: L.sounds.rainOnUmbrella(), src: rainOnUmbrellaSrc, category: 'rain' },
|
||||
{ id: 'rain/rain-on-window', name: L.sounds.rainOnWindow(), src: rainOnWindowSrc, category: 'rain' },
|
||||
|
||||
// 物品声音
|
||||
{ id: 'things/boiling-water', name: L.sounds.boilingWater(), src: boilingWaterSrc, category: 'things' },
|
||||
{ id: 'things/bubbles', name: L.sounds.bubbles(), src: bubblesSrc, category: 'things' },
|
||||
{ id: 'things/ceiling-fan', name: L.sounds.ceilingFan(), src: ceilingFanSrc, category: 'things' },
|
||||
{ id: 'things/clock', name: L.sounds.clock(), src: clockSrc, category: 'things' },
|
||||
{ id: 'things/dryer', name: L.sounds.dryer(), src: dryerSrc, category: 'things' },
|
||||
{ id: 'things/keyboard', name: L.sounds.keyboard(), src: keyboardSrc, category: 'things' },
|
||||
{ id: 'things/paper', name: L.sounds.paper(), src: paperSrc, category: 'things' },
|
||||
{ id: 'things/typewriter', name: L.sounds.typewriter(), src: typewriterSrc, category: 'things' },
|
||||
{ id: 'things/washing-machine', name: L.sounds.washingMachine(), src: washingMachineSrc, category: 'things' },
|
||||
{ id: 'things/wind-chimes', name: L.sounds.windChimes(), src: windChimesSrc, category: 'things' },
|
||||
|
||||
|
||||
// 城市声音
|
||||
{ id: 'urban/crowd', name: L.sounds.crowd(), src: crowdSrc, category: 'urban' },
|
||||
{ id: 'urban/fireworks', name: L.sounds.fireworks(), src: fireworksSrc, category: 'urban' },
|
||||
{ id: 'urban/highway', name: L.sounds.highway(), src: highwaySrc, category: 'urban' },
|
||||
{ id: 'urban/road', name: L.sounds.road(), src: roadSrc, category: 'urban' },
|
||||
{ id: 'urban/traffic', name: L.sounds.traffic(), src: trafficSrc, category: 'urban' },
|
||||
];
|
||||
10
src/audio.d.ts
vendored
Normal file
10
src/audio.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// 声明音频文件模块类型
|
||||
declare module '*.mp3' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.wav' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
570
src/components/StatusBar.tsx
Normal file
570
src/components/StatusBar.tsx
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from './ui/popover';
|
||||
import { WhiteNoiseSettings } from 'src/types';
|
||||
import { categories, getSoundById, playAlert, playSound, sounds, stopSound, setVolume } from 'src/lib/sound';
|
||||
import { sample } from 'lodash-es';
|
||||
import { formatTime } from 'src/lib/time';
|
||||
import { Toggle } from "./ui/toggle";
|
||||
import { Slider } from "./ui/slider";
|
||||
import { Icon } from "./ui/icon";
|
||||
import { cn } from 'src/lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
import L from 'src/L';
|
||||
|
||||
const timerOptions = [
|
||||
{ value: 0.1, label: 'test' },
|
||||
{ value: 5, label: L.timerOptions['5']() },
|
||||
{ value: 10, label: L.timerOptions['10']() },
|
||||
{ value: 15, label: L.timerOptions['15']() },
|
||||
{ value: 20, label: L.timerOptions['20']() },
|
||||
{ value: 25, label: L.timerOptions['25']() },
|
||||
{ value: 30, label: L.timerOptions['30']() },
|
||||
{ value: 45, label: L.timerOptions['45']() },
|
||||
{ value: 60, label: L.timerOptions['60']() },
|
||||
{ value: 90, label: L.timerOptions['90']() },
|
||||
{ value: 120, label: L.timerOptions['120']() },
|
||||
];
|
||||
export interface StatusBarProps {
|
||||
defaultSettings: WhiteNoiseSettings;
|
||||
onSaveSettings: (settings: WhiteNoiseSettings) => void;
|
||||
}
|
||||
|
||||
export const StatusBar = ({ defaultSettings, onSaveSettings }: StatusBarProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentSettings, setCurrentSettings] = useState(defaultSettings);
|
||||
const [volumeIconIndex, setVolumeIconIndex] = useState(0);
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const [isTimerOn, setIsTimerOn] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [timer, setTimer] = useState(defaultSettings.defaultTimer);
|
||||
|
||||
const timerIntervalRef = useRef<number | null>(null);
|
||||
const timerEndTimeRef = useRef<number | null>(null);
|
||||
const pausedRemainingMsRef = useRef<number | null>(null);
|
||||
const currentSettingsRef = useRef<WhiteNoiseSettings>(defaultSettings);
|
||||
|
||||
const play = useCallback((soundId?: string) => {
|
||||
const sound = getSoundById(soundId || currentSettings.defaultSound);
|
||||
if (sound) {
|
||||
stopSound();
|
||||
playSound(sound, currentSettings.volume);
|
||||
}
|
||||
}, [currentSettings.defaultSound, currentSettings.volume]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false);
|
||||
stopSound();
|
||||
} else {
|
||||
setIsPlaying(true);
|
||||
play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (newVolume: number) => {
|
||||
const volumeValue = newVolume / 100;
|
||||
setCurrentSettings(prev => ({ ...prev, volume: volumeValue }));
|
||||
setVolume(volumeValue);
|
||||
};
|
||||
|
||||
const startTimer = (minutes: number) => {
|
||||
if (timerIntervalRef.current) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
timerIntervalRef.current = null;
|
||||
}
|
||||
|
||||
setTimer(minutes);
|
||||
setIsTimerOn(true);
|
||||
setIsPaused(false);
|
||||
|
||||
const ms = minutes * 60 * 1000;
|
||||
const endTime = Date.now() + ms;
|
||||
timerEndTimeRef.current = endTime;
|
||||
pausedRemainingMsRef.current = null;
|
||||
|
||||
timerIntervalRef.current = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
const remaining = Math.max(0, endTime - now);
|
||||
|
||||
const remainingMinutes = remaining / 60000;
|
||||
setTimer(remainingMinutes);
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (currentSettingsRef.current.stopSoundOnTimerEnd) {
|
||||
stopSound();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
if (currentSettingsRef.current.playAlertOnTimerEnd) {
|
||||
playAlert();
|
||||
}
|
||||
|
||||
clearInterval(timerIntervalRef.current!);
|
||||
timerIntervalRef.current = null;
|
||||
timerEndTimeRef.current = null;
|
||||
setIsPaused(true);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isTimerOn && !isPaused) {
|
||||
if (timerIntervalRef.current) {
|
||||
return;
|
||||
}
|
||||
startTimer(currentSettings.defaultTimer);
|
||||
} else if (!isTimerOn) {
|
||||
if (timerIntervalRef.current && !isPaused) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
timerIntervalRef.current = null;
|
||||
timerEndTimeRef.current = null;
|
||||
pausedRemainingMsRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [isTimerOn, isPaused, startTimer, currentSettings.defaultTimer]);
|
||||
|
||||
const resetTimer = () => {
|
||||
if (timerIntervalRef.current) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
timerIntervalRef.current = null;
|
||||
}
|
||||
|
||||
setIsPaused(false);
|
||||
setTimer(currentSettings.defaultTimer);
|
||||
pausedRemainingMsRef.current = null;
|
||||
timerEndTimeRef.current = null;
|
||||
};
|
||||
|
||||
const toggleTimerPause = () => {
|
||||
if (!isTimerOn && !isPaused) return;
|
||||
|
||||
if (isPaused) {
|
||||
if (pausedRemainingMsRef.current === null || pausedRemainingMsRef.current <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowTime = Date.now();
|
||||
const endTime = nowTime + pausedRemainingMsRef.current;
|
||||
timerEndTimeRef.current = endTime;
|
||||
|
||||
timerIntervalRef.current = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
const remaining = Math.max(0, endTime - now);
|
||||
|
||||
const remainingMinutes = remaining / 60000;
|
||||
setTimer(remainingMinutes);
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (currentSettings.stopSoundOnTimerEnd) {
|
||||
stopSound();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
clearInterval(timerIntervalRef.current!);
|
||||
timerIntervalRef.current = null;
|
||||
timerEndTimeRef.current = null;
|
||||
setIsPaused(true);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
setIsTimerOn(true);
|
||||
setIsPaused(false);
|
||||
} else {
|
||||
if (timerEndTimeRef.current) {
|
||||
pausedRemainingMsRef.current = Math.max(0, timerEndTimeRef.current - Date.now());
|
||||
} else {
|
||||
pausedRemainingMsRef.current = timer * 60000;
|
||||
}
|
||||
|
||||
if (timerIntervalRef.current) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
timerIntervalRef.current = null;
|
||||
}
|
||||
|
||||
setIsPaused(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
currentSettingsRef.current = currentSettings;
|
||||
onSaveSettings(currentSettings);
|
||||
}, [currentSettings, onSaveSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerIntervalRef.current) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
timerIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const statusBarRef = useRef<HTMLDivElement>(null);
|
||||
const soundChangeTimeoutRef = useRef<number | null>(null);
|
||||
const errorTimeoutRef = useRef<number | null>(null);
|
||||
const volumeIconIntervalRef = useRef<number | null>(null);
|
||||
|
||||
const getTimerStyle = () => {
|
||||
return timer === 0 ? 'wn-text-red-500' : '';
|
||||
};
|
||||
|
||||
const handleRandomSound = useCallback(() => {
|
||||
const sound = sample(sounds);
|
||||
if (sound) {
|
||||
setCurrentSettings(prev => ({ ...prev, defaultSound: sound.id }));
|
||||
play(sound.id);
|
||||
}
|
||||
}, [play]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
if (volumeIconIntervalRef.current) {
|
||||
clearInterval(volumeIconIntervalRef.current);
|
||||
}
|
||||
|
||||
volumeIconIntervalRef.current = window.setInterval(() => {
|
||||
setVolumeIconIndex(prev => {
|
||||
const newIndex = (prev + 1) % 3;
|
||||
return newIndex;
|
||||
});
|
||||
}, 500);
|
||||
} else {
|
||||
if (volumeIconIntervalRef.current) {
|
||||
clearInterval(volumeIconIntervalRef.current);
|
||||
volumeIconIntervalRef.current = null;
|
||||
}
|
||||
setVolumeIconIndex(prev => prev === 0 ? prev : 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (volumeIconIntervalRef.current) {
|
||||
clearInterval(volumeIconIntervalRef.current);
|
||||
volumeIconIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePlay() {
|
||||
setIsPlaying(true);
|
||||
play();
|
||||
}
|
||||
function handlePlayRandom() {
|
||||
handleRandomSound();
|
||||
}
|
||||
function handleStop() {
|
||||
setIsPlaying(false);
|
||||
stopSound();
|
||||
}
|
||||
function handleConfig() {
|
||||
setOpen(true);
|
||||
}
|
||||
document.addEventListener('white-noise-play', handlePlay);
|
||||
document.addEventListener('white-noise-play-random', handlePlayRandom);
|
||||
document.addEventListener('white-noise-stop', handleStop);
|
||||
document.addEventListener('white-noise-config', handleConfig);
|
||||
return () => {
|
||||
document.removeEventListener('white-noise-play', handlePlay);
|
||||
document.removeEventListener('white-noise-play-random', handlePlayRandom);
|
||||
document.removeEventListener('white-noise-stop', handleStop);
|
||||
document.removeEventListener('white-noise-config', handleConfig);
|
||||
};
|
||||
}, [setIsPlaying, play, handleRandomSound, stopSound, setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (soundChangeTimeoutRef.current) {
|
||||
clearTimeout(soundChangeTimeoutRef.current);
|
||||
soundChangeTimeoutRef.current = null;
|
||||
}
|
||||
if (errorTimeoutRef.current) {
|
||||
clearTimeout(errorTimeoutRef.current);
|
||||
errorTimeoutRef.current = null;
|
||||
}
|
||||
if (volumeIconIntervalRef.current) {
|
||||
clearInterval(volumeIconIntervalRef.current);
|
||||
volumeIconIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const selectedSound = document.querySelector('[data-selected="true"]');
|
||||
if (selectedSound) {
|
||||
selectedSound.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
}, [listOpen]);
|
||||
|
||||
const currentSound = useMemo(() => {
|
||||
return getSoundById(currentSettings.defaultSound);
|
||||
}, [currentSettings.defaultSound]);
|
||||
|
||||
const PlaybackControls = (
|
||||
<div className="playback-controls wn-mb-4">
|
||||
<div className="wn-flex wn-flex-row wn-items-center wn-justify-between wn-mb-2 wn-gap-4">
|
||||
<div className='wn-flex wn-flex-col wn-gap-2'>
|
||||
<div className='wn-flex wn-flex-row wn-items-center wn-gap-2 wn-justify-between'>
|
||||
<div className={`wn-text-sm wn-font-medium ${isPlaying ? 'wn-text-primary' : ''}`}>
|
||||
{currentSound?.name || ''}
|
||||
</div>
|
||||
<div className='wn-flex wn-flex-row wn-items-center wn-gap-1'>
|
||||
<Popover open={listOpen} onOpenChange={setListOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className={cn("wn-h-6 wn-w-6 wn-flex wn-items-center wn-justify-center hover:wn-bg-border wn-rounded-md wn-cursor-pointer", listOpen && "wn-bg-border")} aria-label={L.list()}>
|
||||
<div className="icon">
|
||||
<Icon name="list" />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="!wn-w-36 wn-max-h-64 wn-overflow-y-auto">
|
||||
<div className="wn-flex wn-flex-col wn-gap-2">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="wn-flex wn-flex-col wn-gap-1">
|
||||
<div className="wn-text-xs wn-opacity-60">{category.icon} {category.name}</div>
|
||||
<div className="wn-flex wn-flex-col wn-gap-1 wn-text-sm">
|
||||
{category.sounds.map((sound) => (
|
||||
<div
|
||||
key={sound.id}
|
||||
data-selected={currentSound === sound}
|
||||
className={`wn-cursor-pointer wn-text-xs wn-py-1 wn-pl-4 ${currentSound === sound ? 'wn-text-primary wn-font-bold' : ''}`}
|
||||
onClick={() => {
|
||||
setCurrentSettings(prev => ({ ...prev, defaultSound: sound.id }));
|
||||
play(sound.id);
|
||||
setListOpen(false);
|
||||
}}
|
||||
>
|
||||
{sound.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div
|
||||
className="wn-h-6 wn-w-6 wn-flex wn-items-center wn-justify-center wn-cursor-pointer hover:wn-bg-border wn-rounded-md"
|
||||
onClick={handleRandomSound}
|
||||
title={L.playRandom()}
|
||||
>
|
||||
<div className="icon">
|
||||
<Icon name="shuffle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wn-flex wn-flex-row wn-gap-2 wn-items-center wn-h-6 wn-leading-6">
|
||||
<div className="icon !wn-flex wn-h-6 wn-w-6 wn-items-center wn-justify-center wn-cursor-pointer hover:wn-bg-border wn-rounded-md">
|
||||
<Icon name="volume-2" />
|
||||
</div>
|
||||
<Slider
|
||||
value={currentSettings.volume * 100}
|
||||
onChange={(value) => handleVolumeChange(value)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className="obsidian-slider wn-flex"
|
||||
/>
|
||||
<div className="wn-text-xs wn-w-[35px] wn-text-right">{(currentSettings.volume * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wn-flex wn-items-center wn-justify-center wn-flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"white-noise-play-button",
|
||||
"wn-w-[60px] wn-h-[60px] wn-rounded-full !wn-flex wn-items-center wn-justify-center wn-cursor-pointer",
|
||||
isPlaying ? "wn-bg-muted-foreground wn-text-card" : "wn-bg-primary wn-text-card"
|
||||
)}
|
||||
onClick={handlePlayPause}
|
||||
title={isPlaying ? L.pause() : L.play()}
|
||||
>
|
||||
<Icon size={36} name={isPlaying ? "pause" : "play"} className="icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
const handleTimerToggle = (value: boolean) => {
|
||||
setIsTimerOn(value);
|
||||
};
|
||||
|
||||
const handleTimerEndActionChange = (value: boolean) => {
|
||||
setCurrentSettings(prev => ({ ...prev, stopSoundOnTimerEnd: value }));
|
||||
};
|
||||
|
||||
const handleNotifyToggle = (value: boolean) => {
|
||||
setCurrentSettings(prev => ({ ...prev, playAlertOnTimerEnd: value }));
|
||||
};
|
||||
|
||||
const TimerSection = (
|
||||
<div className="timer-section">
|
||||
<div className="wn-flex wn-items-center wn-gap-2 wn-mb-4">
|
||||
<Toggle
|
||||
checked={isTimerOn}
|
||||
onChange={handleTimerToggle}
|
||||
id="timer-toggle"
|
||||
className="obsidian-toggle wn-flex"
|
||||
/>
|
||||
<label htmlFor="timer-toggle" className="wn-text-sm wn-cursor-pointer">{L.enableTimer()}</label>
|
||||
<span className="wn-text-xs wn-text-muted-foreground wn-opacity-60">{L.enableTimerDescription()}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{isTimerOn && (
|
||||
<div className="wn-bg-muted/30 wn-rounded-md wn-p-2 wn-border wn-border-border">
|
||||
<div className='wn-flex wn-flex-row wn-items-center wn-justify-between wn-gap-4'>
|
||||
<div className='wn-flex wn-flex-col wn-gap-2 wn-justify-center'>
|
||||
<div className="wn-flex wn-items-center wn-justify-center wn-w-24">
|
||||
<span className={`wn-text-2xl wn-font-bold wn-font-mono ${getTimerStyle()}`}>
|
||||
{formatTime(timer)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="wn-flex wn-flex-row wn-gap-2 wn-justify-center">
|
||||
{timer > 0 && <button
|
||||
className="clickable-icon icon-btn"
|
||||
onClick={toggleTimerPause}
|
||||
aria-label={isPaused ? L.resumeTimer() : L.pauseTimer()}
|
||||
>
|
||||
<div className="icon">
|
||||
<Icon name={isPaused ? "play" : "pause"} />
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
{timer === 0 && <button
|
||||
className="clickable-icon icon-btn"
|
||||
aria-label={L.timerEnd()}
|
||||
disabled={true}
|
||||
>
|
||||
<div className="icon">
|
||||
<Icon name="circle-check-big" />
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
className="clickable-icon icon-btn"
|
||||
onClick={resetTimer}
|
||||
aria-label={L.resetTimer()}
|
||||
>
|
||||
<div className="icon">
|
||||
<Icon name="timer-reset" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='wn-flex wn-flex-col wn-gap-2 wn-flex-1'>
|
||||
<div className="wn-flex wn-items-center wn-gap-2">
|
||||
<Icon name="timer" className="icon" />
|
||||
{L.selectTimer()}
|
||||
<Select
|
||||
value={String(currentSettings.defaultTimer)}
|
||||
onValueChange={(value) => {
|
||||
setCurrentSettings(prev => ({ ...prev, defaultTimer: Number(value) }));
|
||||
setTimer(Number(value));
|
||||
if (isTimerOn) {
|
||||
startTimer(Number(value));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="!wn-w-24">
|
||||
<SelectValue placeholder={L.selectTimer()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timerOptions.map(option => (
|
||||
<SelectItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="wn-flex wn-flex-col wn-gap-1">
|
||||
<div className="wn-flex wn-items-center wn-gap-2">
|
||||
<Toggle
|
||||
checked={currentSettings.stopSoundOnTimerEnd}
|
||||
onChange={handleTimerEndActionChange}
|
||||
id="timer-end-action"
|
||||
className="obsidian-toggle wn-flex"
|
||||
/>
|
||||
<label htmlFor="timer-end-action" className="wn-text-xs wn-cursor-pointer">
|
||||
{L.timerEndAction()}
|
||||
</label>
|
||||
</div>
|
||||
<div className="wn-flex wn-items-center wn-gap-2">
|
||||
<Toggle
|
||||
checked={currentSettings.playAlertOnTimerEnd}
|
||||
onChange={handleNotifyToggle}
|
||||
id="timer-notify"
|
||||
className="obsidian-toggle wn-flex"
|
||||
/>
|
||||
<label htmlFor="timer-notify" className="wn-text-xs wn-cursor-pointer">
|
||||
{L.timerNotify()}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
ref={statusBarRef}
|
||||
className="status-bar-item"
|
||||
aria-label={L.config()}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<div className="icon">
|
||||
{volumeIconIndex === 0 && <Icon name="volume" />}
|
||||
{volumeIconIndex === 1 && <Icon name="volume-1" />}
|
||||
{volumeIconIndex === 2 && <Icon name="volume-2" />}
|
||||
</div>
|
||||
<span>{currentSound?.name || ''}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="icon">
|
||||
<Icon name="volume-x" />
|
||||
</div>
|
||||
<span>{L.notPlaying()}</span>
|
||||
</>
|
||||
)}
|
||||
{isTimerOn && (
|
||||
<span className={`wn-ml-2 ${getTimerStyle()}`}>
|
||||
{isPaused ? timer <= 0 ? L.stopped() : L.paused() : ''}
|
||||
<span className="wn-font-mono">{formatTime(timer)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="white-noise-pane wn-w-[450px]" align="end">
|
||||
<h3 className="wn-text-sm wn-font-medium wn-mb-4 wn-mt-0 wn-flex wn-items-center wn-gap-2">
|
||||
<Icon name="activity" className="icon"></Icon>
|
||||
{L.config()}
|
||||
</h3>
|
||||
{PlaybackControls}
|
||||
{TimerSection}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
55
src/components/ui/button.tsx
Normal file
55
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"wn:inline-flex wn:items-center wn:justify-center wn:whitespace-nowrap wn:rounded-md wn:text-sm wn:font-medium wn:transition-colors wn:focus-visible:outline-none wn:focus-visible:ring-1 wn:focus-visible:ring-ring wn:disabled:pointer-events-none wn:disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"wn:bg-primary wn:text-primary-foreground wn:shadow wn:hover:wn:bg-primary/90",
|
||||
destructive:
|
||||
"wn:bg-destructive wn:text-destructive-foreground wn:shadow-sm wn:hover:wn:bg-destructive/90",
|
||||
outline:
|
||||
"wn:border wn:border-input wn:bg-transparent wn:shadow-sm wn:hover:wn:bg-accent wn:hover:wn:text-accent-foreground",
|
||||
secondary:
|
||||
"wn:bg-secondary wn:text-secondary-foreground wn:shadow-sm wn:hover:wn:bg-secondary/80",
|
||||
ghost: "wn:hover:wn:bg-accent wn:hover:wn:text-accent-foreground",
|
||||
link: "wn:text-primary wn:underline-offset-4 wn:hover:wn:underline",
|
||||
},
|
||||
size: {
|
||||
default: "wn:h-9 wn:px-4 wn:py-2",
|
||||
sm: "wn:h-8 wn:rounded-md wn:px-3 wn:text-xs",
|
||||
lg: "wn:h-10 wn:rounded-md wn:px-8",
|
||||
icon: "wn:h-9 wn:w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
135
src/components/ui/dropdown.tsx
Normal file
135
src/components/ui/dropdown.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { DropdownComponent } from 'obsidian';
|
||||
|
||||
interface DropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
value: string;
|
||||
options: DropdownOption[];
|
||||
onChange: (value: string) => void;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 Obsidian 的 DropdownComponent 的 React 组件
|
||||
*/
|
||||
export const Dropdown: React.FC<DropdownProps> = ({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
id,
|
||||
disabled = false,
|
||||
className = "",
|
||||
placeholder,
|
||||
}) => {
|
||||
// 容器 ref
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
// 使用 ref 追踪 DropdownComponent 实例
|
||||
const dropdownRef = useRef<DropdownComponent | null>(null);
|
||||
|
||||
// 组件初始化和清理
|
||||
useEffect(() => {
|
||||
// 创建一个新的容器元素
|
||||
const container = document.createElement('div');
|
||||
container.className = className;
|
||||
if (id) container.dataset.id = id;
|
||||
|
||||
// 将容器添加到 DOM
|
||||
if (containerRef.current) {
|
||||
containerRef.current.appendChild(container);
|
||||
}
|
||||
|
||||
// 创建 DropdownComponent 实例
|
||||
const dropdown = new DropdownComponent(container);
|
||||
|
||||
// 添加选项
|
||||
options.forEach(option => {
|
||||
dropdown.addOption(option.value, option.label);
|
||||
});
|
||||
|
||||
// 设置初始值
|
||||
dropdown.setValue(value);
|
||||
|
||||
// 设置占位符文本(如果相关API存在)
|
||||
if (placeholder && 'selectEl' in dropdown) {
|
||||
const selectEl = dropdown.selectEl as HTMLSelectElement;
|
||||
selectEl.setAttribute('placeholder', placeholder);
|
||||
}
|
||||
|
||||
// 设置变更处理器
|
||||
dropdown.onChange(onChange);
|
||||
|
||||
// 如果禁用,添加禁用类
|
||||
if (disabled) {
|
||||
container.classList.add('is-disabled');
|
||||
}
|
||||
|
||||
// 保存引用
|
||||
dropdownRef.current = dropdown;
|
||||
|
||||
// 在组件卸载时清理
|
||||
return () => {
|
||||
if (containerRef.current && container.parentNode === containerRef.current) {
|
||||
containerRef.current.removeChild(container);
|
||||
}
|
||||
dropdownRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 响应选项变化
|
||||
useEffect(() => {
|
||||
if (dropdownRef.current) {
|
||||
// 清除所有选项
|
||||
const selectEl = dropdownRef.current.selectEl;
|
||||
Array.from(selectEl.options).forEach(() => {
|
||||
selectEl.remove(0);
|
||||
});
|
||||
|
||||
// 添加新选项
|
||||
options.forEach(option => {
|
||||
dropdownRef.current?.addOption(option.value, option.label);
|
||||
});
|
||||
|
||||
// 重新设置值
|
||||
dropdownRef.current.setValue(value);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
// 响应值变化
|
||||
useEffect(() => {
|
||||
if (dropdownRef.current && dropdownRef.current.getValue() !== value) {
|
||||
dropdownRef.current.setValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 响应禁用状态变化
|
||||
useEffect(() => {
|
||||
if (dropdownRef.current && containerRef.current) {
|
||||
const container = containerRef.current.firstChild as HTMLElement;
|
||||
if (container) {
|
||||
if (disabled) {
|
||||
container.classList.add('is-disabled');
|
||||
} else {
|
||||
container.classList.remove('is-disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
// 响应占位符变化
|
||||
useEffect(() => {
|
||||
if (dropdownRef.current && placeholder && 'selectEl' in dropdownRef.current) {
|
||||
const selectEl = dropdownRef.current.selectEl as HTMLSelectElement;
|
||||
selectEl.setAttribute('placeholder', placeholder);
|
||||
}
|
||||
}, [placeholder]);
|
||||
|
||||
// 返回一个空的 span 作为容器的父元素
|
||||
return <span ref={containerRef} />;
|
||||
};
|
||||
55
src/components/ui/icon.tsx
Normal file
55
src/components/ui/icon.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { setIcon } from 'obsidian';
|
||||
|
||||
interface IconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 Obsidian 的 setIcon 功能的 React 组件
|
||||
*/
|
||||
export const Icon: React.FC<IconProps> = ({
|
||||
name,
|
||||
size,
|
||||
className = "",
|
||||
onClick,
|
||||
title,
|
||||
}) => {
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当名称变化或组件加载时设置图标
|
||||
useEffect(() => {
|
||||
if (iconRef.current) {
|
||||
// setIcon 只接受两个参数
|
||||
setIcon(iconRef.current, name);
|
||||
|
||||
// 如果需要调整大小,使用 CSS
|
||||
if (size && iconRef.current.firstChild) {
|
||||
// (iconRef.current.firstChild as HTMLElement).setAttribute('width', `${size}px`);
|
||||
// (iconRef.current.firstChild as HTMLElement).setAttribute('height', `${size}px`);
|
||||
(iconRef.current.firstChild as HTMLElement).style.width = `${size}px`;
|
||||
(iconRef.current.firstChild as HTMLElement).style.height = `${size}px`;
|
||||
iconRef.current.style.width = `${size}px`;
|
||||
iconRef.current.style.height = `${size}px`;
|
||||
}
|
||||
}
|
||||
}, [name, size]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={iconRef}
|
||||
className={`obsidian-icon ${className}`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
31
src/components/ui/popover.tsx
Normal file
31
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"wn-z-50 wn-w-72 wn-rounded-md wn-border wn-bg-popover wn-p-4 wn-text-popover-foreground wn-shadow-md wn-outline-none data-[state=open]:wn-animate-in data-[state=closed]:wn-animate-out data-[state=closed]:wn-fade-out-0 data-[state=open]:wn-fade-in-0 data-[state=closed]:wn-zoom-out-95 data-[state=open]:wn-zoom-in-95 data-[side=bottom]:wn-slide-in-from-top-2 data-[side=left]:wn-slide-in-from-right-2 data-[side=right]:wn-slide-in-from-left-2 data-[side=top]:wn-slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { ChevronDown, ChevronUp, Check } from "lucide-react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"wn-flex wn-h-6 wn-w-full wn-items-center wn-justify-between wn-whitespace-nowrap wn-rounded-md wn-border wn-border-input wn-bg-transparent wn-p-1 wn-text-xs wn-shadow-sm wn-ring-offset-background wn-placeholder:wn-text-muted-foreground wn-focus:wn-outline-none wn-focus:wn-ring-1 wn-focus:wn-ring-ring disabled:wn-cursor-not-allowed disabled:wn-opacity-50 [&>span]:wn-line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="wn-h-4 wn-w-4 wn-opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"wn-flex wn-cursor-default wn-items-center wn-justify-center wn-py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="wn-h-4 wn-w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"wn-flex wn-cursor-default wn-items-center wn-justify-center wn-py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="wn-h-4 wn-w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"wn-relative wn-z-50 wn-max-h-96 wn-min-w-[8rem] wn-overflow-hidden wn-rounded-md wn-border wn-bg-popover wn-text-popover-foreground wn-shadow-md data-[state=open]:wn-animate-in data-[state=closed]:wn-animate-out data-[state=closed]:wn-fade-out-0 data-[state=open]:wn-fade-in-0 data-[state=closed]:wn-zoom-out-95 data-[state=open]:wn-zoom-in-95 data-[side=bottom]:wn-slide-in-from-top-2 data-[side=left]:wn-slide-in-from-right-2 data-[side=right]:wn-slide-in-from-left-2 data-[side=top]:wn-slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:wn-translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:wn-translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"wn-p-1",
|
||||
position === "popper" &&
|
||||
"wn-h-[var(--radix-select-trigger-height)] wn-full wn-min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("wn-px-1 wn-py-1 wn-text-xs wn-font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"wn-relative wn-flex wn-w-full wn-cursor-default wn-select-none wn-items-center wn-rounded-sm wn-py-1 wn-pl-2 wn-pr-8 wn-text-xs wn-outline-none wn-focus:wn-bg-accent wn-focus:wn-text-accent-foreground data-[disabled]:wn-pointer-events-none data-[disabled]:wn-opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="wn-absolute wn-right-2 wn-flex wn-h-3.5 wn-w-3.5 wn-items-center wn-justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="wn-h-4 wn-w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("wn-mx-1 wn-my-1 wn-h-px wn-bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"wn-shrink-0 wn-bg-border",
|
||||
orientation === "horizontal" ? "wn-h-[1px] wn-w-full" : "wn-h-full wn-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
100
src/components/ui/slider.tsx
Normal file
100
src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { SliderComponent } from 'obsidian';
|
||||
|
||||
interface SliderProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 Obsidian 的 SliderComponent 的 React 组件
|
||||
*/
|
||||
export const Slider: React.FC<SliderProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 1,
|
||||
step = 0.01,
|
||||
id,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}) => {
|
||||
// 容器 ref
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
// 使用 ref 追踪 SliderComponent 实例
|
||||
const sliderRef = useRef<SliderComponent | null>(null);
|
||||
|
||||
// 组件初始化和清理
|
||||
useEffect(() => {
|
||||
// 创建一个新的容器元素
|
||||
const container = document.createElement('div');
|
||||
container.className = className;
|
||||
if (id) container.dataset.id = id;
|
||||
|
||||
// 将容器添加到 DOM
|
||||
if (containerRef.current) {
|
||||
containerRef.current.appendChild(container);
|
||||
}
|
||||
|
||||
// 创建 SliderComponent 实例
|
||||
const slider = new SliderComponent(container);
|
||||
|
||||
// 设置滑块属性
|
||||
slider.setLimits(min, max, step);
|
||||
slider.setValue(value);
|
||||
slider.onChange(onChange);
|
||||
|
||||
// 如果禁用,添加禁用类
|
||||
if (disabled) {
|
||||
container.classList.add('is-disabled');
|
||||
}
|
||||
|
||||
// 保存引用
|
||||
sliderRef.current = slider;
|
||||
|
||||
// 在组件卸载时清理
|
||||
return () => {
|
||||
if (containerRef.current && container.parentNode === containerRef.current) {
|
||||
containerRef.current.removeChild(container);
|
||||
}
|
||||
sliderRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 响应值变化
|
||||
useEffect(() => {
|
||||
if (sliderRef.current && sliderRef.current.getValue() !== value) {
|
||||
sliderRef.current.setValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 响应最小值、最大值、步长变化
|
||||
useEffect(() => {
|
||||
if (sliderRef.current) {
|
||||
sliderRef.current.setLimits(min, max, step);
|
||||
}
|
||||
}, [min, max, step]);
|
||||
|
||||
// 响应禁用状态变化
|
||||
useEffect(() => {
|
||||
if (sliderRef.current && containerRef.current) {
|
||||
const container = containerRef.current.firstChild as HTMLElement;
|
||||
if (container) {
|
||||
if (disabled) {
|
||||
container.classList.add('is-disabled');
|
||||
} else {
|
||||
container.classList.remove('is-disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
// 返回一个空的 span 作为容器的父元素
|
||||
return <span ref={containerRef} />;
|
||||
};
|
||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"wn-peer wn-inline-flex wn-h-5 wn-w-9 wn-shrink-0 wn-cursor-pointer wn-items-center wn-rounded-full wn-border-2 wn-border-transparent wn-transition-colors focus-visible:wn-outline-none focus-visible:wn-ring-2 focus-visible:wn-ring-ring focus-visible:wn-ring-offset-2 focus-visible:wn-ring-offset-background disabled:wn-cursor-not-allowed disabled:wn-opacity-50 data-[state=checked]:wn-bg-primary data-[state=unchecked]:wn-bg-input wn-p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"wn-pointer-events-none wn-block wn-h-4 wn-w-4 wn-rounded-full wn-bg-background wn-shadow-lg wn-ring-0 wn-transition-transform data-[state=checked]:wn-translate-x-4 data-[state=unchecked]:wn-translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"wn-inline-flex wn-h-9 wn-items-center wn-justify-center wn-rounded-lg wn-bg-muted wn-p-1 wn-text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"wn-inline-flex wn-items-center wn-justify-center wn-whitespace-nowrap wn-rounded-md wn-px-3 wn-py-1 wn-text-sm wn-font-medium wn-ring-offset-background wn-transition-all focus-visible:wn-outline-none focus-visible:wn-ring-2 focus-visible:wn-ring-ring focus-visible:wn-ring-offset-2 disabled:wn-pointer-events-none disabled:wn-opacity-50 data-[state=active]:wn-bg-background data-[state=active]:wn-text-foreground data-[state=active]:wn-shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"wn-mt-2 wn-ring-offset-background focus-visible:wn-outline-none focus-visible:wn-ring-2 focus-visible:wn-ring-ring focus-visible:wn-ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
80
src/components/ui/toggle.tsx
Normal file
80
src/components/ui/toggle.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { ToggleComponent } from 'obsidian';
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 Obsidian 的 ToggleComponent 的 React 组件
|
||||
*/
|
||||
export const Toggle: React.FC<ToggleProps> = ({
|
||||
checked,
|
||||
onChange,
|
||||
id,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}) => {
|
||||
// 容器 ref
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
// 使用 ref 追踪 ToggleComponent 实例
|
||||
const toggleRef = useRef<ToggleComponent | null>(null);
|
||||
|
||||
// 组件初始化和清理
|
||||
useEffect(() => {
|
||||
// 创建一个新的容器元素,而不是使用 React 创建的 div
|
||||
const container = document.createElement('div');
|
||||
container.className = className;
|
||||
|
||||
// 将容器添加到 DOM
|
||||
if (containerRef.current) {
|
||||
containerRef.current.appendChild(container);
|
||||
}
|
||||
|
||||
// 创建 ToggleComponent 实例
|
||||
const toggle = new ToggleComponent(container);
|
||||
toggle.setValue(checked);
|
||||
toggle.onChange(onChange);
|
||||
|
||||
if (id) {
|
||||
container.dataset.id = id;
|
||||
toggle.toggleEl.firstElementChild?.setAttribute('id', id);
|
||||
}
|
||||
|
||||
// 保存引用
|
||||
toggleRef.current = toggle;
|
||||
|
||||
// 在组件卸载时清理
|
||||
return () => {
|
||||
if (containerRef.current && container.parentNode === containerRef.current) {
|
||||
containerRef.current.removeChild(container);
|
||||
}
|
||||
toggleRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 响应 checked 属性变化
|
||||
useEffect(() => {
|
||||
if (toggleRef.current) {
|
||||
toggleRef.current.setValue(checked);
|
||||
}
|
||||
}, [checked]);
|
||||
|
||||
// 响应 disabled 属性变化
|
||||
useEffect(() => {
|
||||
if (toggleRef.current && toggleRef.current.toggleEl) {
|
||||
if (disabled) {
|
||||
toggleRef.current.toggleEl.classList.add('is-disabled');
|
||||
} else {
|
||||
toggleRef.current.toggleEl.classList.remove('is-disabled');
|
||||
}
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
// 返回一个空的 span 作为容器的父元素
|
||||
return <span ref={containerRef} />;
|
||||
};
|
||||
33
src/globals.css
Normal file
33
src/globals.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
:root {
|
||||
--wn-ring: 222.2 84% 4.9%;
|
||||
|
||||
--wn-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
--wn-ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
|
||||
*, :after, :before {
|
||||
border: 0 solid #e5e7eb;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
|
||||
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgba(59, 130, 246, .5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
}
|
||||
101
src/i18n/en/index.ts
Normal file
101
src/i18n/en/index.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { Translation } from '../i18n-types.js'
|
||||
|
||||
const en = {
|
||||
// this is an example Translation, just rename or delete this folder if you want
|
||||
play: 'Play white noise',
|
||||
pause: 'Pause white noise',
|
||||
stop: 'Stop white noise',
|
||||
playRandom: 'Play random white noise',
|
||||
openConfig: 'Open white noise configuration',
|
||||
timerOptions: {
|
||||
'5': '5 minutes',
|
||||
'10': '10 minutes',
|
||||
'15': '15 minutes',
|
||||
'20': '20 minutes',
|
||||
'25': '25 minutes',
|
||||
'30': '30 minutes',
|
||||
'45': '45 minutes',
|
||||
'60': '1 hour',
|
||||
'90': '1.5 hours',
|
||||
'120': '2 hours',
|
||||
},
|
||||
config: 'White Noise Settings',
|
||||
paused: '[Paused] ',
|
||||
stopped: '[Time up] ',
|
||||
notPlaying: 'Not playing white noise',
|
||||
list: 'White Noise List',
|
||||
enableTimer: 'Enable timer',
|
||||
enableTimerDescription: 'Can be used as a simple tomato clock',
|
||||
resumeTimer: 'Resume',
|
||||
pauseTimer: 'Pause',
|
||||
timerEnd: 'Timer ended',
|
||||
resetTimer: 'Reset timer',
|
||||
selectTimer: 'Set timer',
|
||||
timerEndAction: 'Stop white noise when timer ends',
|
||||
timerNotify: 'Play notification when timer ends',
|
||||
sounds: {
|
||||
birds: 'Birds',
|
||||
crickets: 'Crickets',
|
||||
crows: 'Crows',
|
||||
dogBarking: 'Dog barking',
|
||||
horseGalopp: 'Horse galopp',
|
||||
owl: 'Owl',
|
||||
seagulls: 'Seagulls',
|
||||
whale: 'Whale',
|
||||
campfire: 'Campfire',
|
||||
droplets: 'Droplets',
|
||||
howlingWind: 'Howling wind',
|
||||
river: 'River',
|
||||
walkInSnow: 'Walk in snow',
|
||||
walkOnLeaves: 'Walk on leaves',
|
||||
waterfall: 'Waterfall',
|
||||
waves: 'Waves',
|
||||
brownNoise: 'Brown noise',
|
||||
pinkNoise: 'Pink noise',
|
||||
whiteNoise: 'White noise',
|
||||
cafe: 'Cafe',
|
||||
church: 'Church',
|
||||
constructionSite: 'Construction site',
|
||||
crowdedBar: 'Crowded bar',
|
||||
laboratory: 'Laboratory',
|
||||
laundryRoom: 'Laundry room',
|
||||
nightVillage: 'Night village',
|
||||
office: 'Office',
|
||||
subwayStation: 'Subway station',
|
||||
supermarket: 'Supermarket',
|
||||
underwater: 'Underwater',
|
||||
heavyRain: 'Heavy rain',
|
||||
lightRain: 'Light rain',
|
||||
rainOnLeaves: 'Rain on leaves',
|
||||
rainOnUmbrella: 'Rain on umbrella',
|
||||
rainOnWindow: 'Rain on window',
|
||||
boilingWater: 'Boiling water',
|
||||
bubbles: 'Bubbles',
|
||||
ceilingFan: 'Ceiling fan',
|
||||
clock: 'Clock',
|
||||
dryer: 'Dryer',
|
||||
keyboard: 'Keyboard',
|
||||
paper: 'Paper',
|
||||
typewriter: 'Typewriter',
|
||||
washingMachine: 'Washing machine',
|
||||
windChimes: 'Wind chimes',
|
||||
crowd: 'Crowd',
|
||||
fireworks: 'Fireworks',
|
||||
highway: 'Highway',
|
||||
road: 'Road',
|
||||
traffic: 'Traffic',
|
||||
},
|
||||
categories: {
|
||||
animals: 'Animals',
|
||||
nature: 'Nature',
|
||||
noise: 'Noise',
|
||||
places: 'Places',
|
||||
rain: 'Rain',
|
||||
things: 'Things',
|
||||
transport: 'Transport',
|
||||
urban: 'Urban',
|
||||
synthetic: 'Synthetic',
|
||||
}
|
||||
} satisfies Translation
|
||||
|
||||
export default en
|
||||
11
src/i18n/formatters.ts
Normal file
11
src/i18n/formatters.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { FormattersInitializer } from 'typesafe-i18n'
|
||||
import type { Locales, Formatters } from './i18n-types.js'
|
||||
|
||||
export const initFormatters: FormattersInitializer<Locales, Formatters> = (_locale: Locales) => {
|
||||
|
||||
const formatters: Formatters = {
|
||||
// add your formatter functions here
|
||||
}
|
||||
|
||||
return formatters
|
||||
}
|
||||
738
src/i18n/i18n-types.ts
Normal file
738
src/i18n/i18n-types.ts
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
import type { BaseTranslation as BaseTranslationType, LocalizedString } from 'typesafe-i18n'
|
||||
|
||||
export type BaseTranslation = BaseTranslationType
|
||||
export type BaseLocale = 'zh'
|
||||
|
||||
export type Locales =
|
||||
| 'en'
|
||||
| 'zh'
|
||||
|
||||
export type Translation = RootTranslation
|
||||
|
||||
export type Translations = RootTranslation
|
||||
|
||||
type RootTranslation = {
|
||||
/**
|
||||
* 播放白噪音
|
||||
*/
|
||||
play: string
|
||||
/**
|
||||
* 暂停白噪音
|
||||
*/
|
||||
pause: string
|
||||
/**
|
||||
* 停止白噪音
|
||||
*/
|
||||
stop: string
|
||||
/**
|
||||
* 随机播放白噪音
|
||||
*/
|
||||
playRandom: string
|
||||
/**
|
||||
* 打开白噪音配置
|
||||
*/
|
||||
openConfig: string
|
||||
timerOptions: {
|
||||
/**
|
||||
* 5分钟
|
||||
*/
|
||||
'5': string
|
||||
/**
|
||||
* 10分钟
|
||||
*/
|
||||
'10': string
|
||||
/**
|
||||
* 15分钟
|
||||
*/
|
||||
'15': string
|
||||
/**
|
||||
* 20分钟
|
||||
*/
|
||||
'20': string
|
||||
/**
|
||||
* 25分钟
|
||||
*/
|
||||
'25': string
|
||||
/**
|
||||
* 30分钟
|
||||
*/
|
||||
'30': string
|
||||
/**
|
||||
* 45分钟
|
||||
*/
|
||||
'45': string
|
||||
/**
|
||||
* 1小时
|
||||
*/
|
||||
'60': string
|
||||
/**
|
||||
* 1.5小时
|
||||
*/
|
||||
'90': string
|
||||
/**
|
||||
* 2小时
|
||||
*/
|
||||
'120': string
|
||||
}
|
||||
/**
|
||||
* 白噪音设置
|
||||
*/
|
||||
config: string
|
||||
/**
|
||||
* [暂停]
|
||||
*/
|
||||
paused: string
|
||||
/**
|
||||
* [时间到]
|
||||
*/
|
||||
stopped: string
|
||||
/**
|
||||
* 白噪音未播放
|
||||
*/
|
||||
notPlaying: string
|
||||
/**
|
||||
* 白噪音列表
|
||||
*/
|
||||
list: string
|
||||
/**
|
||||
* 启用定时器
|
||||
*/
|
||||
enableTimer: string
|
||||
/**
|
||||
* 可用作简易番茄钟
|
||||
*/
|
||||
enableTimerDescription: string
|
||||
/**
|
||||
* 继续
|
||||
*/
|
||||
resumeTimer: string
|
||||
/**
|
||||
* 暂停
|
||||
*/
|
||||
pauseTimer: string
|
||||
/**
|
||||
* 计时结束
|
||||
*/
|
||||
timerEnd: string
|
||||
/**
|
||||
* 重置计时器
|
||||
*/
|
||||
resetTimer: string
|
||||
/**
|
||||
* 设定时间
|
||||
*/
|
||||
selectTimer: string
|
||||
/**
|
||||
* 计时结束时停止白噪音
|
||||
*/
|
||||
timerEndAction: string
|
||||
/**
|
||||
* 计时结束时播放提示音
|
||||
*/
|
||||
timerNotify: string
|
||||
sounds: {
|
||||
/**
|
||||
* 鸟叫
|
||||
*/
|
||||
birds: string
|
||||
/**
|
||||
* 蟋蟀
|
||||
*/
|
||||
crickets: string
|
||||
/**
|
||||
* 乌鸦
|
||||
*/
|
||||
crows: string
|
||||
/**
|
||||
* 狗吠
|
||||
*/
|
||||
dogBarking: string
|
||||
/**
|
||||
* 马蹄声
|
||||
*/
|
||||
horseGalopp: string
|
||||
/**
|
||||
* 猫头鹰
|
||||
*/
|
||||
owl: string
|
||||
/**
|
||||
* 海鸥
|
||||
*/
|
||||
seagulls: string
|
||||
/**
|
||||
* 鲸鱼
|
||||
*/
|
||||
whale: string
|
||||
/**
|
||||
* 篝火
|
||||
*/
|
||||
campfire: string
|
||||
/**
|
||||
* 水滴
|
||||
*/
|
||||
droplets: string
|
||||
/**
|
||||
* 呼啸的风
|
||||
*/
|
||||
howlingWind: string
|
||||
/**
|
||||
* 河流
|
||||
*/
|
||||
river: string
|
||||
/**
|
||||
* 雪地行走
|
||||
*/
|
||||
walkInSnow: string
|
||||
/**
|
||||
* 落叶行走
|
||||
*/
|
||||
walkOnLeaves: string
|
||||
/**
|
||||
* 瀑布
|
||||
*/
|
||||
waterfall: string
|
||||
/**
|
||||
* 海浪
|
||||
*/
|
||||
waves: string
|
||||
/**
|
||||
* 棕噪音
|
||||
*/
|
||||
brownNoise: string
|
||||
/**
|
||||
* 粉噪音
|
||||
*/
|
||||
pinkNoise: string
|
||||
/**
|
||||
* 白噪音
|
||||
*/
|
||||
whiteNoise: string
|
||||
/**
|
||||
* 咖啡馆
|
||||
*/
|
||||
cafe: string
|
||||
/**
|
||||
* 教堂
|
||||
*/
|
||||
church: string
|
||||
/**
|
||||
* 建筑工地
|
||||
*/
|
||||
constructionSite: string
|
||||
/**
|
||||
* 拥挤的酒吧
|
||||
*/
|
||||
crowdedBar: string
|
||||
/**
|
||||
* 实验室
|
||||
*/
|
||||
laboratory: string
|
||||
/**
|
||||
* 洗衣房
|
||||
*/
|
||||
laundryRoom: string
|
||||
/**
|
||||
* 夜晚的村庄
|
||||
*/
|
||||
nightVillage: string
|
||||
/**
|
||||
* 办公室
|
||||
*/
|
||||
office: string
|
||||
/**
|
||||
* 地铁站
|
||||
*/
|
||||
subwayStation: string
|
||||
/**
|
||||
* 超市
|
||||
*/
|
||||
supermarket: string
|
||||
/**
|
||||
* 水下
|
||||
*/
|
||||
underwater: string
|
||||
/**
|
||||
* 大雨
|
||||
*/
|
||||
heavyRain: string
|
||||
/**
|
||||
* 小雨
|
||||
*/
|
||||
lightRain: string
|
||||
/**
|
||||
* 雨打叶
|
||||
*/
|
||||
rainOnLeaves: string
|
||||
/**
|
||||
* 雨打伞
|
||||
*/
|
||||
rainOnUmbrella: string
|
||||
/**
|
||||
* 雨打窗
|
||||
*/
|
||||
rainOnWindow: string
|
||||
/**
|
||||
* 沸水
|
||||
*/
|
||||
boilingWater: string
|
||||
/**
|
||||
* 气泡
|
||||
*/
|
||||
bubbles: string
|
||||
/**
|
||||
* 吊扇
|
||||
*/
|
||||
ceilingFan: string
|
||||
/**
|
||||
* 时钟
|
||||
*/
|
||||
clock: string
|
||||
/**
|
||||
* 烘干机
|
||||
*/
|
||||
dryer: string
|
||||
/**
|
||||
* 键盘
|
||||
*/
|
||||
keyboard: string
|
||||
/**
|
||||
* 纸张
|
||||
*/
|
||||
paper: string
|
||||
/**
|
||||
* 打字机
|
||||
*/
|
||||
typewriter: string
|
||||
/**
|
||||
* 洗衣机
|
||||
*/
|
||||
washingMachine: string
|
||||
/**
|
||||
* 风铃
|
||||
*/
|
||||
windChimes: string
|
||||
/**
|
||||
* 人群
|
||||
*/
|
||||
crowd: string
|
||||
/**
|
||||
* 烟花
|
||||
*/
|
||||
fireworks: string
|
||||
/**
|
||||
* 高速公路
|
||||
*/
|
||||
highway: string
|
||||
/**
|
||||
* 道路
|
||||
*/
|
||||
road: string
|
||||
/**
|
||||
* 交通
|
||||
*/
|
||||
traffic: string
|
||||
}
|
||||
categories: {
|
||||
/**
|
||||
* 动物
|
||||
*/
|
||||
animals: string
|
||||
/**
|
||||
* 自然环境
|
||||
*/
|
||||
nature: string
|
||||
/**
|
||||
* 基础噪音
|
||||
*/
|
||||
noise: string
|
||||
/**
|
||||
* 场所环境
|
||||
*/
|
||||
places: string
|
||||
/**
|
||||
* 雨声
|
||||
*/
|
||||
rain: string
|
||||
/**
|
||||
* 物品声音
|
||||
*/
|
||||
things: string
|
||||
/**
|
||||
* 交通工具
|
||||
*/
|
||||
transport: string
|
||||
/**
|
||||
* 城市声音
|
||||
*/
|
||||
urban: string
|
||||
/**
|
||||
* 合成噪音
|
||||
*/
|
||||
synthetic: string
|
||||
}
|
||||
}
|
||||
|
||||
export type TranslationFunctions = {
|
||||
/**
|
||||
* 播放白噪音
|
||||
*/
|
||||
play: () => LocalizedString
|
||||
/**
|
||||
* 暂停白噪音
|
||||
*/
|
||||
pause: () => LocalizedString
|
||||
/**
|
||||
* 停止白噪音
|
||||
*/
|
||||
stop: () => LocalizedString
|
||||
/**
|
||||
* 随机播放白噪音
|
||||
*/
|
||||
playRandom: () => LocalizedString
|
||||
/**
|
||||
* 打开白噪音配置
|
||||
*/
|
||||
openConfig: () => LocalizedString
|
||||
timerOptions: {
|
||||
/**
|
||||
* 5分钟
|
||||
*/
|
||||
'5': () => LocalizedString
|
||||
/**
|
||||
* 10分钟
|
||||
*/
|
||||
'10': () => LocalizedString
|
||||
/**
|
||||
* 15分钟
|
||||
*/
|
||||
'15': () => LocalizedString
|
||||
/**
|
||||
* 20分钟
|
||||
*/
|
||||
'20': () => LocalizedString
|
||||
/**
|
||||
* 25分钟
|
||||
*/
|
||||
'25': () => LocalizedString
|
||||
/**
|
||||
* 30分钟
|
||||
*/
|
||||
'30': () => LocalizedString
|
||||
/**
|
||||
* 45分钟
|
||||
*/
|
||||
'45': () => LocalizedString
|
||||
/**
|
||||
* 1小时
|
||||
*/
|
||||
'60': () => LocalizedString
|
||||
/**
|
||||
* 1.5小时
|
||||
*/
|
||||
'90': () => LocalizedString
|
||||
/**
|
||||
* 2小时
|
||||
*/
|
||||
'120': () => LocalizedString
|
||||
}
|
||||
/**
|
||||
* 白噪音设置
|
||||
*/
|
||||
config: () => LocalizedString
|
||||
/**
|
||||
* [暂停]
|
||||
*/
|
||||
paused: () => LocalizedString
|
||||
/**
|
||||
* [时间到]
|
||||
*/
|
||||
stopped: () => LocalizedString
|
||||
/**
|
||||
* 白噪音未播放
|
||||
*/
|
||||
notPlaying: () => LocalizedString
|
||||
/**
|
||||
* 白噪音列表
|
||||
*/
|
||||
list: () => LocalizedString
|
||||
/**
|
||||
* 启用定时器
|
||||
*/
|
||||
enableTimer: () => LocalizedString
|
||||
/**
|
||||
* 可用作简易番茄钟
|
||||
*/
|
||||
enableTimerDescription: () => LocalizedString
|
||||
/**
|
||||
* 继续
|
||||
*/
|
||||
resumeTimer: () => LocalizedString
|
||||
/**
|
||||
* 暂停
|
||||
*/
|
||||
pauseTimer: () => LocalizedString
|
||||
/**
|
||||
* 计时结束
|
||||
*/
|
||||
timerEnd: () => LocalizedString
|
||||
/**
|
||||
* 重置计时器
|
||||
*/
|
||||
resetTimer: () => LocalizedString
|
||||
/**
|
||||
* 设定时间
|
||||
*/
|
||||
selectTimer: () => LocalizedString
|
||||
/**
|
||||
* 计时结束时停止白噪音
|
||||
*/
|
||||
timerEndAction: () => LocalizedString
|
||||
/**
|
||||
* 计时结束时播放提示音
|
||||
*/
|
||||
timerNotify: () => LocalizedString
|
||||
sounds: {
|
||||
/**
|
||||
* 鸟叫
|
||||
*/
|
||||
birds: () => LocalizedString
|
||||
/**
|
||||
* 蟋蟀
|
||||
*/
|
||||
crickets: () => LocalizedString
|
||||
/**
|
||||
* 乌鸦
|
||||
*/
|
||||
crows: () => LocalizedString
|
||||
/**
|
||||
* 狗吠
|
||||
*/
|
||||
dogBarking: () => LocalizedString
|
||||
/**
|
||||
* 马蹄声
|
||||
*/
|
||||
horseGalopp: () => LocalizedString
|
||||
/**
|
||||
* 猫头鹰
|
||||
*/
|
||||
owl: () => LocalizedString
|
||||
/**
|
||||
* 海鸥
|
||||
*/
|
||||
seagulls: () => LocalizedString
|
||||
/**
|
||||
* 鲸鱼
|
||||
*/
|
||||
whale: () => LocalizedString
|
||||
/**
|
||||
* 篝火
|
||||
*/
|
||||
campfire: () => LocalizedString
|
||||
/**
|
||||
* 水滴
|
||||
*/
|
||||
droplets: () => LocalizedString
|
||||
/**
|
||||
* 呼啸的风
|
||||
*/
|
||||
howlingWind: () => LocalizedString
|
||||
/**
|
||||
* 河流
|
||||
*/
|
||||
river: () => LocalizedString
|
||||
/**
|
||||
* 雪地行走
|
||||
*/
|
||||
walkInSnow: () => LocalizedString
|
||||
/**
|
||||
* 落叶行走
|
||||
*/
|
||||
walkOnLeaves: () => LocalizedString
|
||||
/**
|
||||
* 瀑布
|
||||
*/
|
||||
waterfall: () => LocalizedString
|
||||
/**
|
||||
* 海浪
|
||||
*/
|
||||
waves: () => LocalizedString
|
||||
/**
|
||||
* 棕噪音
|
||||
*/
|
||||
brownNoise: () => LocalizedString
|
||||
/**
|
||||
* 粉噪音
|
||||
*/
|
||||
pinkNoise: () => LocalizedString
|
||||
/**
|
||||
* 白噪音
|
||||
*/
|
||||
whiteNoise: () => LocalizedString
|
||||
/**
|
||||
* 咖啡馆
|
||||
*/
|
||||
cafe: () => LocalizedString
|
||||
/**
|
||||
* 教堂
|
||||
*/
|
||||
church: () => LocalizedString
|
||||
/**
|
||||
* 建筑工地
|
||||
*/
|
||||
constructionSite: () => LocalizedString
|
||||
/**
|
||||
* 拥挤的酒吧
|
||||
*/
|
||||
crowdedBar: () => LocalizedString
|
||||
/**
|
||||
* 实验室
|
||||
*/
|
||||
laboratory: () => LocalizedString
|
||||
/**
|
||||
* 洗衣房
|
||||
*/
|
||||
laundryRoom: () => LocalizedString
|
||||
/**
|
||||
* 夜晚的村庄
|
||||
*/
|
||||
nightVillage: () => LocalizedString
|
||||
/**
|
||||
* 办公室
|
||||
*/
|
||||
office: () => LocalizedString
|
||||
/**
|
||||
* 地铁站
|
||||
*/
|
||||
subwayStation: () => LocalizedString
|
||||
/**
|
||||
* 超市
|
||||
*/
|
||||
supermarket: () => LocalizedString
|
||||
/**
|
||||
* 水下
|
||||
*/
|
||||
underwater: () => LocalizedString
|
||||
/**
|
||||
* 大雨
|
||||
*/
|
||||
heavyRain: () => LocalizedString
|
||||
/**
|
||||
* 小雨
|
||||
*/
|
||||
lightRain: () => LocalizedString
|
||||
/**
|
||||
* 雨打叶
|
||||
*/
|
||||
rainOnLeaves: () => LocalizedString
|
||||
/**
|
||||
* 雨打伞
|
||||
*/
|
||||
rainOnUmbrella: () => LocalizedString
|
||||
/**
|
||||
* 雨打窗
|
||||
*/
|
||||
rainOnWindow: () => LocalizedString
|
||||
/**
|
||||
* 沸水
|
||||
*/
|
||||
boilingWater: () => LocalizedString
|
||||
/**
|
||||
* 气泡
|
||||
*/
|
||||
bubbles: () => LocalizedString
|
||||
/**
|
||||
* 吊扇
|
||||
*/
|
||||
ceilingFan: () => LocalizedString
|
||||
/**
|
||||
* 时钟
|
||||
*/
|
||||
clock: () => LocalizedString
|
||||
/**
|
||||
* 烘干机
|
||||
*/
|
||||
dryer: () => LocalizedString
|
||||
/**
|
||||
* 键盘
|
||||
*/
|
||||
keyboard: () => LocalizedString
|
||||
/**
|
||||
* 纸张
|
||||
*/
|
||||
paper: () => LocalizedString
|
||||
/**
|
||||
* 打字机
|
||||
*/
|
||||
typewriter: () => LocalizedString
|
||||
/**
|
||||
* 洗衣机
|
||||
*/
|
||||
washingMachine: () => LocalizedString
|
||||
/**
|
||||
* 风铃
|
||||
*/
|
||||
windChimes: () => LocalizedString
|
||||
/**
|
||||
* 人群
|
||||
*/
|
||||
crowd: () => LocalizedString
|
||||
/**
|
||||
* 烟花
|
||||
*/
|
||||
fireworks: () => LocalizedString
|
||||
/**
|
||||
* 高速公路
|
||||
*/
|
||||
highway: () => LocalizedString
|
||||
/**
|
||||
* 道路
|
||||
*/
|
||||
road: () => LocalizedString
|
||||
/**
|
||||
* 交通
|
||||
*/
|
||||
traffic: () => LocalizedString
|
||||
}
|
||||
categories: {
|
||||
/**
|
||||
* 动物
|
||||
*/
|
||||
animals: () => LocalizedString
|
||||
/**
|
||||
* 自然环境
|
||||
*/
|
||||
nature: () => LocalizedString
|
||||
/**
|
||||
* 基础噪音
|
||||
*/
|
||||
noise: () => LocalizedString
|
||||
/**
|
||||
* 场所环境
|
||||
*/
|
||||
places: () => LocalizedString
|
||||
/**
|
||||
* 雨声
|
||||
*/
|
||||
rain: () => LocalizedString
|
||||
/**
|
||||
* 物品声音
|
||||
*/
|
||||
things: () => LocalizedString
|
||||
/**
|
||||
* 交通工具
|
||||
*/
|
||||
transport: () => LocalizedString
|
||||
/**
|
||||
* 城市声音
|
||||
*/
|
||||
urban: () => LocalizedString
|
||||
/**
|
||||
* 合成噪音
|
||||
*/
|
||||
synthetic: () => LocalizedString
|
||||
}
|
||||
}
|
||||
|
||||
export type Formatters = {}
|
||||
27
src/i18n/i18n-util.async.ts
Normal file
27
src/i18n/i18n-util.async.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { initFormatters } from './formatters.js'
|
||||
import type { Locales, Translations } from './i18n-types.js'
|
||||
import { loadedFormatters, loadedLocales, locales } from './i18n-util.js'
|
||||
|
||||
const localeTranslationLoaders = {
|
||||
en: () => import('./en/index.js'),
|
||||
zh: () => import('./zh/index.js'),
|
||||
}
|
||||
|
||||
const updateDictionary = (locale: Locales, dictionary: Partial<Translations>): Translations =>
|
||||
loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary }
|
||||
|
||||
export const importLocaleAsync = async (locale: Locales): Promise<Translations> =>
|
||||
(await localeTranslationLoaders[locale]()).default as unknown as Translations
|
||||
|
||||
export const loadLocaleAsync = async (locale: Locales): Promise<void> => {
|
||||
updateDictionary(locale, await importLocaleAsync(locale))
|
||||
loadFormatters(locale)
|
||||
}
|
||||
|
||||
export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync))
|
||||
|
||||
export const loadFormatters = (locale: Locales): void =>
|
||||
void (loadedFormatters[locale] = initFormatters(locale))
|
||||
26
src/i18n/i18n-util.sync.ts
Normal file
26
src/i18n/i18n-util.sync.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { initFormatters } from './formatters.js'
|
||||
import type { Locales, Translations } from './i18n-types.js'
|
||||
import { loadedFormatters, loadedLocales, locales } from './i18n-util.js'
|
||||
|
||||
import en from './en/index.js'
|
||||
import zh from './zh/index.js'
|
||||
|
||||
const localeTranslations = {
|
||||
en,
|
||||
zh,
|
||||
}
|
||||
|
||||
export const loadLocale = (locale: Locales): void => {
|
||||
if (loadedLocales[locale]) return
|
||||
|
||||
loadedLocales[locale] = localeTranslations[locale] as unknown as Translations
|
||||
loadFormatters(locale)
|
||||
}
|
||||
|
||||
export const loadAllLocales = (): void => locales.forEach(loadLocale)
|
||||
|
||||
export const loadFormatters = (locale: Locales): void =>
|
||||
void (loadedFormatters[locale] = initFormatters(locale))
|
||||
38
src/i18n/i18n-util.ts
Normal file
38
src/i18n/i18n-util.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n'
|
||||
import type { LocaleDetector } from 'typesafe-i18n/detectors'
|
||||
import type { LocaleTranslationFunctions, TranslateByString } from 'typesafe-i18n'
|
||||
import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors'
|
||||
import { initExtendDictionary } from 'typesafe-i18n/utils'
|
||||
import type { Formatters, Locales, Translations, TranslationFunctions } from './i18n-types.js'
|
||||
|
||||
export const baseLocale: Locales = 'zh'
|
||||
|
||||
export const locales: Locales[] = [
|
||||
'en',
|
||||
'zh'
|
||||
]
|
||||
|
||||
export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales)
|
||||
|
||||
export const loadedLocales: Record<Locales, Translations> = {} as Record<Locales, Translations>
|
||||
|
||||
export const loadedFormatters: Record<Locales, Formatters> = {} as Record<Locales, Formatters>
|
||||
|
||||
export const extendDictionary = initExtendDictionary<Translations>()
|
||||
|
||||
export const i18nString = (locale: Locales): TranslateByString => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale])
|
||||
|
||||
export const i18nObject = (locale: Locales): TranslationFunctions =>
|
||||
initI18nObject<Locales, Translations, TranslationFunctions, Formatters>(
|
||||
locale,
|
||||
loadedLocales[locale],
|
||||
loadedFormatters[locale]
|
||||
)
|
||||
|
||||
export const i18n = (): LocaleTranslationFunctions<Locales, Translations, TranslationFunctions> =>
|
||||
initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
|
||||
|
||||
export const detectLocale = (...detectors: LocaleDetector[]): Locales => detectLocaleFn<Locales>(baseLocale, locales, ...detectors)
|
||||
101
src/i18n/zh/index.ts
Normal file
101
src/i18n/zh/index.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { BaseTranslation } from '../i18n-types.js'
|
||||
|
||||
const zh = {
|
||||
// TODO: your translations go here
|
||||
play: '播放白噪音',
|
||||
pause: '暂停白噪音',
|
||||
stop: '停止白噪音',
|
||||
playRandom: '随机播放白噪音',
|
||||
openConfig: '打开白噪音配置',
|
||||
timerOptions: {
|
||||
'5': '5分钟',
|
||||
'10': '10分钟',
|
||||
'15': '15分钟',
|
||||
'20': '20分钟',
|
||||
'25': '25分钟',
|
||||
'30': '30分钟',
|
||||
'45': '45分钟',
|
||||
'60': '1小时',
|
||||
'90': '1.5小时',
|
||||
'120': '2小时',
|
||||
},
|
||||
config: '白噪音设置',
|
||||
paused: '[暂停] ',
|
||||
stopped: '[时间到] ',
|
||||
notPlaying: '白噪音未播放',
|
||||
list: '白噪音列表',
|
||||
enableTimer: '启用定时器',
|
||||
enableTimerDescription: '可用作简易番茄钟',
|
||||
resumeTimer: '继续',
|
||||
pauseTimer: '暂停',
|
||||
timerEnd: '计时结束',
|
||||
resetTimer: '重置计时器',
|
||||
selectTimer: '设定时间',
|
||||
timerEndAction: '计时结束时停止白噪音',
|
||||
timerNotify: '计时结束时播放提示音',
|
||||
sounds: {
|
||||
birds: '鸟叫',
|
||||
crickets: '蟋蟀',
|
||||
crows: '乌鸦',
|
||||
dogBarking: '狗吠',
|
||||
horseGalopp: '马蹄声',
|
||||
owl: '猫头鹰',
|
||||
seagulls: '海鸥',
|
||||
whale: '鲸鱼',
|
||||
campfire: '篝火',
|
||||
droplets: '水滴',
|
||||
howlingWind: '呼啸的风',
|
||||
river: '河流',
|
||||
walkInSnow: '雪地行走',
|
||||
walkOnLeaves: '落叶行走',
|
||||
waterfall: '瀑布',
|
||||
waves: '海浪',
|
||||
brownNoise: '棕噪音',
|
||||
pinkNoise: '粉噪音',
|
||||
whiteNoise: '白噪音',
|
||||
cafe: '咖啡馆',
|
||||
church: '教堂',
|
||||
constructionSite: '建筑工地',
|
||||
crowdedBar: '拥挤的酒吧',
|
||||
laboratory: '实验室',
|
||||
laundryRoom: '洗衣房',
|
||||
nightVillage: '夜晚的村庄',
|
||||
office: '办公室',
|
||||
subwayStation: '地铁站',
|
||||
supermarket: '超市',
|
||||
underwater: '水下',
|
||||
heavyRain: '大雨',
|
||||
lightRain: '小雨',
|
||||
rainOnLeaves: '雨打叶',
|
||||
rainOnUmbrella: '雨打伞',
|
||||
rainOnWindow: '雨打窗',
|
||||
boilingWater: '沸水',
|
||||
bubbles: '气泡',
|
||||
ceilingFan: '吊扇',
|
||||
clock: '时钟',
|
||||
dryer: '烘干机',
|
||||
keyboard: '键盘',
|
||||
paper: '纸张',
|
||||
typewriter: '打字机',
|
||||
washingMachine: '洗衣机',
|
||||
windChimes: '风铃',
|
||||
crowd: '人群',
|
||||
fireworks: '烟花',
|
||||
highway: '高速公路',
|
||||
road: '道路',
|
||||
traffic: '交通',
|
||||
},
|
||||
categories: {
|
||||
animals: '动物',
|
||||
nature: '自然环境',
|
||||
noise: '基础噪音',
|
||||
places: '场所环境',
|
||||
rain: '雨声',
|
||||
things: '物品声音',
|
||||
transport: '交通工具',
|
||||
urban: '城市声音',
|
||||
synthetic: '合成噪音',
|
||||
}
|
||||
} satisfies BaseTranslation
|
||||
|
||||
export default zh
|
||||
220
src/lib/noise-generator.ts
Normal file
220
src/lib/noise-generator.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* 噪音生成器
|
||||
* 用于生成各种类型的噪音(白噪音、粉噪音、棕噪音等)
|
||||
*/
|
||||
|
||||
export type NoiseType = 'white' | 'pink' | 'brown' | 'green';
|
||||
|
||||
export class NoiseGenerator {
|
||||
private audioContext: AudioContext;
|
||||
private noiseNode: AudioNode | null = null;
|
||||
private gainNode: GainNode | null = null;
|
||||
private isPlaying: boolean = false;
|
||||
private noiseType: NoiseType = 'white';
|
||||
|
||||
constructor() {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
setNoiseType(type: NoiseType): void {
|
||||
this.noiseType = type;
|
||||
if (this.isPlaying) {
|
||||
this.stop();
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.value = volume;
|
||||
}
|
||||
}
|
||||
|
||||
play(): void {
|
||||
if (this.isPlaying) return;
|
||||
|
||||
this.gainNode = this.audioContext.createGain();
|
||||
this.gainNode.gain.value = 0.5; // 默认音量
|
||||
this.gainNode.connect(this.audioContext.destination);
|
||||
|
||||
switch (this.noiseType) {
|
||||
case 'white':
|
||||
this.noiseNode = this.createWhiteNoise();
|
||||
break;
|
||||
case 'pink':
|
||||
this.noiseNode = this.createPinkNoise();
|
||||
break;
|
||||
case 'brown':
|
||||
this.noiseNode = this.createBrownNoise();
|
||||
break;
|
||||
case 'green':
|
||||
this.noiseNode = this.createGreenNoise();
|
||||
break;
|
||||
default:
|
||||
this.noiseNode = this.createWhiteNoise();
|
||||
}
|
||||
|
||||
if (this.noiseNode) {
|
||||
this.noiseNode.connect(this.gainNode);
|
||||
this.isPlaying = true;
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
if (this.noiseNode) {
|
||||
this.noiseNode.disconnect();
|
||||
this.noiseNode = null;
|
||||
}
|
||||
|
||||
if (this.gainNode) {
|
||||
this.gainNode.disconnect();
|
||||
this.gainNode = null;
|
||||
}
|
||||
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
private createWhiteNoise(): AudioNode {
|
||||
const bufferSize = 2 * this.audioContext.sampleRate;
|
||||
const noiseBuffer = this.audioContext.createBuffer(
|
||||
1, // 单声道
|
||||
bufferSize,
|
||||
this.audioContext.sampleRate
|
||||
);
|
||||
const output = noiseBuffer.getChannelData(0);
|
||||
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
output[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
|
||||
const whiteNoise = this.audioContext.createBufferSource();
|
||||
whiteNoise.buffer = noiseBuffer;
|
||||
whiteNoise.loop = true;
|
||||
whiteNoise.start(0);
|
||||
|
||||
const highShelf = this.audioContext.createBiquadFilter();
|
||||
highShelf.type = 'highshelf';
|
||||
highShelf.frequency.value = 4000;
|
||||
highShelf.gain.value = 6; // 增强高频
|
||||
|
||||
const lowShelf = this.audioContext.createBiquadFilter();
|
||||
lowShelf.type = 'lowshelf';
|
||||
lowShelf.frequency.value = 300;
|
||||
lowShelf.gain.value = -3; // 轻微降低低频
|
||||
|
||||
whiteNoise.connect(highShelf);
|
||||
highShelf.connect(lowShelf);
|
||||
|
||||
return lowShelf;
|
||||
}
|
||||
|
||||
private createPinkNoise(): AudioNode {
|
||||
const bufferSize = 2 * this.audioContext.sampleRate;
|
||||
const noiseBuffer = this.audioContext.createBuffer(
|
||||
1, // 单声道
|
||||
bufferSize,
|
||||
this.audioContext.sampleRate
|
||||
);
|
||||
const output = noiseBuffer.getChannelData(0);
|
||||
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
output[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
|
||||
const noise = this.audioContext.createBufferSource();
|
||||
noise.buffer = noiseBuffer;
|
||||
noise.loop = true;
|
||||
noise.start(0);
|
||||
|
||||
const filterCount = 6;
|
||||
let filters: BiquadFilterNode[] = [];
|
||||
|
||||
for (let i = 0; i < filterCount; i++) {
|
||||
const filter = this.audioContext.createBiquadFilter();
|
||||
filter.type = 'lowshelf';
|
||||
filter.frequency.value = 100 * Math.pow(2, i);
|
||||
filter.gain.value = -3.0; // 保持经典的粉噪音特性
|
||||
filters.push(filter);
|
||||
}
|
||||
|
||||
const lowBoost = this.audioContext.createBiquadFilter();
|
||||
lowBoost.type = 'lowshelf';
|
||||
lowBoost.frequency.value = 160;
|
||||
lowBoost.gain.value = 3; // 适度增强低频
|
||||
|
||||
const midBoost = this.audioContext.createBiquadFilter();
|
||||
midBoost.type = 'peaking';
|
||||
midBoost.frequency.value = 1200;
|
||||
midBoost.Q.value = 1.0;
|
||||
midBoost.gain.value = 1.5;
|
||||
|
||||
noise.connect(filters[0]);
|
||||
for (let i = 0; i < filterCount - 1; i++) {
|
||||
filters[i].connect(filters[i + 1]);
|
||||
}
|
||||
|
||||
filters[filterCount - 1].connect(lowBoost);
|
||||
lowBoost.connect(midBoost);
|
||||
|
||||
return midBoost;
|
||||
}
|
||||
|
||||
private createBrownNoise(): AudioNode {
|
||||
const bufferSize = 4096;
|
||||
const brownNoise = this.audioContext.createScriptProcessor(bufferSize, 1, 1);
|
||||
|
||||
let lastOut = 0.0;
|
||||
brownNoise.onaudioprocess = function (e) {
|
||||
const output = e.outputBuffer.getChannelData(0);
|
||||
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
|
||||
lastOut = (lastOut + (0.02 * white)) / 1.02;
|
||||
|
||||
output[i] = lastOut * 3.5;
|
||||
}
|
||||
};
|
||||
|
||||
return brownNoise;
|
||||
}
|
||||
|
||||
private createGreenNoise(): AudioNode {
|
||||
const whiteNoise = this.createWhiteNoise();
|
||||
|
||||
const bandpassFilter = this.audioContext.createBiquadFilter();
|
||||
bandpassFilter.type = 'bandpass';
|
||||
bandpassFilter.frequency.value = 1000; // 中心频率 1kHz
|
||||
bandpassFilter.Q.value = 0.5; // 较宽的 Q 值使频带更宽
|
||||
|
||||
const preGain = this.audioContext.createGain();
|
||||
preGain.gain.value = 1.5;
|
||||
|
||||
whiteNoise.connect(preGain);
|
||||
preGain.connect(bandpassFilter);
|
||||
|
||||
const lowShelf = this.audioContext.createBiquadFilter();
|
||||
lowShelf.type = 'lowshelf';
|
||||
lowShelf.frequency.value = 300;
|
||||
lowShelf.gain.value = -3;
|
||||
|
||||
const highShelf = this.audioContext.createBiquadFilter();
|
||||
highShelf.type = 'highshelf';
|
||||
highShelf.frequency.value = 2500;
|
||||
highShelf.gain.value = -3;
|
||||
|
||||
bandpassFilter.connect(lowShelf);
|
||||
lowShelf.connect(highShelf);
|
||||
|
||||
return highShelf;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
if (this.audioContext.state !== 'closed') {
|
||||
this.audioContext.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/lib/react-renderer.tsx
Normal file
38
src/lib/react-renderer.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
|
||||
export class ReactRenderer {
|
||||
private static instances: Map<string, ReactDOM.Root> = new Map();
|
||||
|
||||
static render(
|
||||
id: string,
|
||||
element: React.ReactNode,
|
||||
container: HTMLElement
|
||||
): void {
|
||||
this.unmount(id);
|
||||
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
{element}
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
this.instances.set(id, root);
|
||||
}
|
||||
|
||||
static unmount(id: string): void {
|
||||
const root = this.instances.get(id);
|
||||
if (root) {
|
||||
root.unmount();
|
||||
this.instances.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
static unmountAll(): void {
|
||||
for (const [_, root] of this.instances.entries()) {
|
||||
root.unmount();
|
||||
}
|
||||
this.instances.clear();
|
||||
}
|
||||
}
|
||||
47
src/lib/react-types.ts
Normal file
47
src/lib/react-types.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { IWhiteNoisePlugin, Sound } from "../types";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface StatusBarProps {
|
||||
plugin: IWhiteNoisePlugin;
|
||||
onOpenPopover: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export interface PopoverProps {
|
||||
plugin: IWhiteNoisePlugin;
|
||||
onClose: () => void;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface TabsContainerProps {
|
||||
children: ReactNode;
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
tabs: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SoundListProps {
|
||||
sounds: Sound[];
|
||||
currentSound: Sound | null;
|
||||
onSoundSelect: (sound: Sound) => void;
|
||||
}
|
||||
|
||||
export interface PlaybackControlsProps {
|
||||
plugin: IWhiteNoisePlugin;
|
||||
isPlaying: boolean;
|
||||
onPlayPause: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export interface VolumeControlProps {
|
||||
volume: number;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
}
|
||||
|
||||
export interface TimerControlProps {
|
||||
timerEndTime: number | null;
|
||||
onTimerChange: (minutes: number) => void;
|
||||
}
|
||||
150
src/lib/sound.ts
Normal file
150
src/lib/sound.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { audioAssets, alarmSrc } from "src/audio-assets";
|
||||
import { Sound, SoundCategory } from "src/types";
|
||||
import { NoiseGenerator, NoiseType } from "./noise-generator";
|
||||
import L from "src/L";
|
||||
|
||||
// 定义所有类别
|
||||
export const categories: SoundCategory[] = [
|
||||
{ id: 'animals', name: L.categories.animals(), icon: '🐾', sounds: [] },
|
||||
{ id: 'nature', name: L.categories.nature(), icon: '🌿', sounds: [] },
|
||||
{ id: 'noise', name: L.categories.noise(), icon: '📻', sounds: [] },
|
||||
{ id: 'places', name: L.categories.places(), icon: '🏙️', sounds: [] },
|
||||
{ id: 'rain', name: L.categories.rain(), icon: '🌧️', sounds: [] },
|
||||
{ id: 'things', name: L.categories.things(), icon: '🔔', sounds: [] },
|
||||
{ id: 'urban', name: L.categories.urban(), icon: '🏙️', sounds: [] },
|
||||
{ id: 'synthetic', name: L.categories.synthetic(), icon: '🔊', sounds: [] }, // 新增合成噪音分类
|
||||
];
|
||||
|
||||
// 从音频资源加载声音
|
||||
export const sounds = audioAssets;
|
||||
|
||||
// 添加合成噪音
|
||||
sounds.push({
|
||||
id: 'synthetic-white',
|
||||
name: '白噪音',
|
||||
src: 'synthetic:white',
|
||||
category: 'synthetic',
|
||||
synthetic: true
|
||||
});
|
||||
|
||||
sounds.push({
|
||||
id: 'synthetic-pink',
|
||||
name: '粉噪音',
|
||||
src: 'synthetic:pink',
|
||||
category: 'synthetic',
|
||||
synthetic: true
|
||||
});
|
||||
|
||||
sounds.push({
|
||||
id: 'synthetic-brown',
|
||||
name: '棕噪音',
|
||||
src: 'synthetic:brown',
|
||||
category: 'synthetic',
|
||||
synthetic: true
|
||||
});
|
||||
|
||||
sounds.push({
|
||||
id: 'synthetic-green',
|
||||
name: '绿噪音',
|
||||
src: 'synthetic:green',
|
||||
category: 'synthetic',
|
||||
synthetic: true
|
||||
});
|
||||
|
||||
// 将声音归类到对应分类
|
||||
sounds.forEach(sound => {
|
||||
const category = categories.find(c => c.id === sound.category);
|
||||
if (category) {
|
||||
category.sounds.push(sound);
|
||||
}
|
||||
});
|
||||
|
||||
export function getSoundById(id: string): Sound | undefined {
|
||||
return sounds.find(sound => sound.id === id);
|
||||
}
|
||||
|
||||
// 修复数据URL的MIME类型
|
||||
export function fixDataUrlMimeType(dataUrl: string): string {
|
||||
// 检查是否是data URL
|
||||
if (!dataUrl.startsWith('data:')) return dataUrl;
|
||||
|
||||
// 确定正确的MIME类型
|
||||
let mimeType = 'audio/mpeg'; // 默认为MP3
|
||||
|
||||
// 替换MIME类型
|
||||
if (dataUrl.startsWith('data:application/octet-stream;')) {
|
||||
return dataUrl.replace('data:application/octet-stream;', `data:${mimeType};`);
|
||||
}
|
||||
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
let noiseGenerator: NoiseGenerator | null = null;
|
||||
const audioElement = new Audio();
|
||||
let isSyntheticNoise = false;
|
||||
|
||||
// 播放合成噪音
|
||||
export function playSyntheticNoise(sound: Sound, volume: number) {
|
||||
// 如果噪音生成器不存在,创建一个
|
||||
if (!noiseGenerator) {
|
||||
noiseGenerator = new NoiseGenerator();
|
||||
}
|
||||
|
||||
// 从src中提取噪音类型 (格式: synthetic:type)
|
||||
const noiseType = sound.src.split(':')[1] as NoiseType;
|
||||
|
||||
// 设置噪音类型
|
||||
noiseGenerator.setNoiseType(noiseType);
|
||||
|
||||
// 设置音量
|
||||
noiseGenerator.setVolume(volume);
|
||||
|
||||
// 播放噪音
|
||||
noiseGenerator.play();
|
||||
|
||||
// 设置合成噪音标志
|
||||
isSyntheticNoise = true;
|
||||
}
|
||||
|
||||
export function stopSound() {
|
||||
if (isSyntheticNoise) {
|
||||
noiseGenerator?.stop();
|
||||
} else {
|
||||
audioElement.pause();
|
||||
}
|
||||
|
||||
// 重置合成噪音标志
|
||||
isSyntheticNoise = false;
|
||||
}
|
||||
|
||||
export function playSound(sound: Sound, volume: number) {
|
||||
stopSound();
|
||||
if (sound.synthetic) {
|
||||
playSyntheticNoise(sound, volume);
|
||||
} else {
|
||||
audioElement.src = fixDataUrlMimeType(sound.src);
|
||||
audioElement.volume = volume;
|
||||
audioElement.play();
|
||||
audioElement.loop = true;
|
||||
isSyntheticNoise = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 设置音量
|
||||
export function setVolume(volume: number) {
|
||||
if (isSyntheticNoise && noiseGenerator) {
|
||||
noiseGenerator.setVolume(volume);
|
||||
} else {
|
||||
audioElement.volume = volume;
|
||||
}
|
||||
}
|
||||
|
||||
export function playAlert() {
|
||||
const audioElement = new Audio();
|
||||
audioElement.src = alarmSrc;
|
||||
audioElement.volume = 1;
|
||||
audioElement.play();
|
||||
audioElement.addEventListener('ended', () => {
|
||||
audioElement.remove();
|
||||
});
|
||||
}
|
||||
6
src/lib/time.ts
Normal file
6
src/lib/time.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// 时间格式化,将分钟转换为 mm:ss 格式
|
||||
export const formatTime = (minutes: number): string => {
|
||||
const mins = Math.floor(minutes);
|
||||
const secs = Math.floor((minutes % 1) * 60);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
7
src/lib/utils.ts
Normal file
7
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// 合并 className
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
40
src/main.css
Normal file
40
src/main.css
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/* 引入 TailwindCSS 样式 */
|
||||
@import "./globals.css";
|
||||
|
||||
/* 白噪音状态栏和面板样式 */
|
||||
.status-bar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 0 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 状态栏悬停样式 */
|
||||
.status-bar-item:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
/* 红色文本样式 */
|
||||
.wn-text-red-500 {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
/* 图标容器样式 */
|
||||
.obsidian-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.white-noise-play-button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.white-noise-play-button polygon {
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
}
|
||||
89
src/main.ts
Normal file
89
src/main.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Plugin } from 'obsidian';
|
||||
import { WhiteNoiseSettings, DEFAULT_SETTINGS, IWhiteNoisePlugin } from './types';
|
||||
import { ReactRenderer } from "./lib/react-renderer";
|
||||
import { StatusBar } from "./components/StatusBar";
|
||||
import { createElement } from 'react';
|
||||
import L from './L';
|
||||
|
||||
export default class WhiteNoisePlugin extends Plugin implements IWhiteNoisePlugin {
|
||||
settings: WhiteNoiseSettings = DEFAULT_SETTINGS;
|
||||
statusBarItem: HTMLElement | null = null;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.registerCommands();
|
||||
this.createStatusBar();
|
||||
}
|
||||
|
||||
onunload() {
|
||||
ReactRenderer.unmountAll();
|
||||
}
|
||||
|
||||
createStatusBar() {
|
||||
this.statusBarItem = this.addStatusBarItem();
|
||||
this.statusBarItem.addClasses(['white-noise-status-bar', 'mod-clickable']);
|
||||
|
||||
this.updateStatusBar();
|
||||
}
|
||||
|
||||
updateStatusBar() {
|
||||
if (!this.statusBarItem) return;
|
||||
ReactRenderer.render(
|
||||
'white-noise-status-bar',
|
||||
createElement(StatusBar, {
|
||||
defaultSettings: this.settings,
|
||||
onSaveSettings: (settings: WhiteNoiseSettings) => {
|
||||
this.settings = settings;
|
||||
this.saveSettings();
|
||||
}
|
||||
}),
|
||||
this.statusBarItem
|
||||
);
|
||||
}
|
||||
|
||||
registerCommands() {
|
||||
this.addCommand({
|
||||
id: 'play',
|
||||
name: L.play(),
|
||||
callback: () => {
|
||||
const event = new CustomEvent('white-noise-play');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'stop',
|
||||
name: L.stop(),
|
||||
callback: () => {
|
||||
const event = new CustomEvent('white-noise-stop');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'play-random',
|
||||
name: L.playRandom(),
|
||||
callback: () => {
|
||||
const event = new CustomEvent('white-noise-play-random');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'config',
|
||||
name: L.openConfig(),
|
||||
callback: () => {
|
||||
const event = new CustomEvent('white-noise-config');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
39
src/types.ts
Normal file
39
src/types.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// 白噪音分类定义
|
||||
export interface SoundCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
sounds: Sound[];
|
||||
}
|
||||
|
||||
// 声音定义
|
||||
export interface Sound {
|
||||
id: string;
|
||||
name: string;
|
||||
src: string;
|
||||
category: string;
|
||||
synthetic?: boolean; // 添加synthetic可选属性,标识是否为合成声音
|
||||
}
|
||||
|
||||
// 插件设置接口
|
||||
export interface WhiteNoiseSettings {
|
||||
volume: number;
|
||||
defaultSound: string;
|
||||
defaultTimer: number; // 分钟
|
||||
stopSoundOnTimerEnd: boolean; // 定时器结束时是否停止声音
|
||||
playAlertOnTimerEnd: boolean; // 定时器结束时是否播放提示音
|
||||
}
|
||||
|
||||
// 默认设置
|
||||
export const DEFAULT_SETTINGS: WhiteNoiseSettings = {
|
||||
volume: 0.5,
|
||||
defaultSound: 'rain/light-rain',
|
||||
defaultTimer: 0, // 0 表示无定时器
|
||||
stopSoundOnTimerEnd: true, // 默认定时器结束时停止声音
|
||||
playAlertOnTimerEnd: false, // 默认定时器结束时不播放提示音
|
||||
};
|
||||
|
||||
export interface IWhiteNoisePlugin {
|
||||
settings: WhiteNoiseSettings;
|
||||
updateStatusBar(): void;
|
||||
}
|
||||
53
tailwind.config.js
Normal file
53
tailwind.config.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
darkMode: 'class',
|
||||
prefix: 'wn-',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "var(--background-modifier-border)",
|
||||
input: "var(--background-modifier-border)",
|
||||
ring: "hsl(var(--wn-ring))",
|
||||
background: "var(--background-primary)",
|
||||
foreground: "var(--text-normal)",
|
||||
primary: {
|
||||
DEFAULT: "var(--interactive-accent)",
|
||||
foreground: "var(--text-on-accent)",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "var(--background-secondary)",
|
||||
foreground: "var(--text-normal)",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "var(--text-error)",
|
||||
foreground: "var(--text-on-accent)",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "var(--text-muted)",
|
||||
foreground: "var(--text-normal)",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "var(--interactive-accent)",
|
||||
foreground: "var(--text-on-accent)",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "var(--background-primary)",
|
||||
foreground: "var(--text-normal)",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "var(--background-primary)",
|
||||
foreground: "var(--text-normal)",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--wn-radius)",
|
||||
md: "calc(var(--wn-radius) - 2px)",
|
||||
sm: "calc(var(--wn-radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
35
tsconfig.json
Normal file
35
tsconfig.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"extends": "@waveform-audio/inf/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7",
|
||||
"ES2021"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"types": [
|
||||
"obsidian-typings"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
]
|
||||
}
|
||||
18
version-bump.mjs
Normal file
18
version-bump.mjs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import process from 'process';
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
let manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t'));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
let versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.0.1": "1.7.7"
|
||||
}
|
||||
Loading…
Reference in a new issue