This commit is contained in:
Zhou Hua 2025-03-01 00:45:39 +08:00
parent 3b44e71606
commit 8b76a41186
100 changed files with 10771 additions and 1 deletions

15
.github/FUNDING.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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) 以了解更多信息。

View file

@ -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
View 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
View 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
View file

@ -0,0 +1,3 @@
import baseConfig from '@waveform/inf/eslint';
export default baseConfig;

10
manifest.json Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

View 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

Binary file not shown.

BIN
sounds/animals/birds.mp3 Normal file

Binary file not shown.

BIN
sounds/animals/crickets.mp3 Normal file

Binary file not shown.

BIN
sounds/animals/crows.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/animals/owl.mp3 Normal file

Binary file not shown.

BIN
sounds/animals/seagulls.mp3 Normal file

Binary file not shown.

BIN
sounds/animals/whale.mp3 Normal file

Binary file not shown.

BIN
sounds/nature/campfire.mp3 Normal file

Binary file not shown.

BIN
sounds/nature/droplets.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
sounds/nature/river.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/nature/waterfall.mp3 Normal file

Binary file not shown.

BIN
sounds/nature/waves.mp3 Normal file

Binary file not shown.

BIN
sounds/noise/brown-noise.wav Executable file

Binary file not shown.

BIN
sounds/noise/pink-noise.wav Executable file

Binary file not shown.

BIN
sounds/noise/white-noise.wav Executable file

Binary file not shown.

BIN
sounds/places/cafe.mp3 Normal file

Binary file not shown.

BIN
sounds/places/church.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/places/office.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/rain/heavy-rain.mp3 Normal file

Binary file not shown.

BIN
sounds/rain/light-rain.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/things/bubbles.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
sounds/things/clock.mp3 Normal file

Binary file not shown.

BIN
sounds/things/dryer.mp3 Normal file

Binary file not shown.

BIN
sounds/things/keyboard.mp3 Normal file

Binary file not shown.

BIN
sounds/things/paper.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/urban/crowd.mp3 Normal file

Binary file not shown.

BIN
sounds/urban/fireworks.mp3 Normal file

Binary file not shown.

BIN
sounds/urban/highway.mp3 Normal file

Binary file not shown.

BIN
sounds/urban/road.mp3 Normal file

Binary file not shown.

BIN
sounds/urban/traffic.mp3 Normal file

Binary file not shown.

26
src/L.ts Normal file
View 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
View 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
View file

@ -0,0 +1,10 @@
// 声明音频文件模块类型
declare module '*.mp3' {
const src: string;
export default src;
}
declare module '*.wav' {
const src: string;
export default src;
}

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

View 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 }

View 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} />;
};

View 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',
}}
/>
);
};

View 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 }

View 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,
}

View 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 }

View 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} />;
};

View 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 }

View 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 }

View 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
View 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
View 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
View 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
View 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 = {}

View 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))

View 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
View 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
View 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
View 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();
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"0.0.1": "1.7.7"
}