26 Commits
ios ... ios-fix

Author SHA1 Message Date
MAZE
ad57f082ca fix: wip for ios 2025-02-18 19:53:20 +03:30
MAZE
54b46123b4 fix: wip for ios 2025-02-18 19:42:05 +03:30
MAZE
699f49bfa3 feat: add binary animation 2025-02-14 15:20:17 +03:30
MAZE
29bebb3ec7 feat: add cipher animation 2025-02-14 15:14:53 +03:30
MAZE
7a47282165 chore: update logos 2025-02-13 20:35:40 +03:30
MAZE
2b85b276eb chore: update logos 2025-02-13 20:32:28 +03:30
MAZE ✧
0a1bf16d18 Merge pull request #54 from underoot/feature/media-controls
feat: media session support
2025-02-13 20:09:11 +03:30
MAZE
10259d013f feat: better heading 2025-02-13 20:04:10 +03:30
MAZE
e61307a302 feat: use custom slider in binaural and isochronic 2025-02-13 19:53:39 +03:30
MAZE
cb340c53a3 feat: add custom checkbox 2025-02-13 19:47:54 +03:30
MAZE
3b77c12114 feat: add custom slider 2025-02-13 19:43:21 +03:30
MAZE
b8ed79f48a feat: remove pre-made binaurals 2025-02-13 19:38:44 +03:30
Aleksandr Shoronov
d3a9f1ddba Removed Media Controls menu item 2025-01-26 10:57:51 +02:00
Aleksandr Shoronov
18ed2e6f05 feat: media session support 2025-01-18 14:06:37 +02:00
MAZE
3b829fce07 feat: add global volume 2024-10-13 21:54:52 +03:30
MAZE
e77c67bc24 fix: better implement shortcuts 2024-10-13 21:32:58 +03:30
MAZE
14c331ab6e fix: add default value 2024-09-13 19:59:29 +03:30
MAZE
5c536786ea style: add style to generators 2024-09-13 15:58:03 +03:30
MAZE
2e1fce4669 style: change icons 2024-09-13 15:28:04 +03:30
MAZE
d759064373 feat: add isochronic tone generator without styles 2024-09-13 15:17:20 +03:30
MAZE
f40e8206f8 feat: add binaural beat generator without styles 2024-09-13 14:55:04 +03:30
MAZE
d2e289e5d5 feat: add more sounds 2024-09-03 18:49:47 +03:30
MAZE
a59db41dc5 feat: change and add shortcuts 2024-09-03 18:30:24 +03:30
MAZE
554309ebd8 feat: add more sounds 2024-09-03 18:27:30 +03:30
MAZE
be38b92647 feat: add more sounds 2024-09-03 18:12:33 +03:30
MAZE
b497d16fd8 feat: add more sounds 2024-09-03 17:51:19 +03:30
63 changed files with 1879 additions and 73 deletions

View File

@@ -42,6 +42,7 @@
"jsx-a11y/no-static-element-interactions": "off",
"jsx-a11y/media-has-caption": "off",
"jsx-a11y/no-noninteractive-tabindex": "off",
"jsx-a11y/label-has-associated-control": "off",
"react/jsx-sort-props": [
"warn",
{

View File

@@ -24,6 +24,7 @@
1. 📓 Notepad for quick notes.
1. 🍅 Pomodoro timer.
1. ✅ Simple to-do list (soon).
1. ⏯️ Media controls.
1. ⌨️ Keyboard shortcuts for everything.
1. 🥷 Privacy focused: no data collection.
1. 💰 Completely free, open-source, and self-hostable.

437
package-lock.json generated
View File

@@ -11,7 +11,9 @@
"@astrojs/react": "3.6.0",
"@floating-ui/react": "0.26.0",
"@formkit/auto-animate": "0.8.2",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.0.6",
"@radix-ui/react-slider": "1.2.3",
"@radix-ui/react-tooltip": "1.0.7",
"@types/howler": "2.2.10",
"@types/react": "^18.2.25",
@@ -4360,6 +4362,12 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz",
@@ -4391,6 +4399,203 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz",
"integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz",
"integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-size": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz",
@@ -4757,6 +4962,223 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz",
"integrity": "sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
"integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz",
"integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-size": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
@@ -4879,6 +5301,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz",
"integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz",

View File

@@ -27,7 +27,9 @@
"@astrojs/react": "3.6.0",
"@floating-ui/react": "0.26.0",
"@formkit/auto-animate": "0.8.2",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.0.6",
"@radix-ui/react-slider": "1.2.3",
"@radix-ui/react-tooltip": "1.0.7",
"@types/howler": "2.2.10",
"@types/react": "^18.2.25",

View File

@@ -1,12 +1,11 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="25" fill="#09090B"/>
<path d="M64 19C76.4264 19 86.5 29.0736 86.5 41.5H75.25C75.25 35.2868 70.2132 30.25 64 30.25C57.7868 30.25 52.75 35.2868 52.75 41.5L41.5 41.5C41.5 29.0736 51.5736 19 64 19Z" fill="white"/>
<path d="M41.5 86.5C29.0736 86.5 19 76.4264 19 64C19 51.5736 29.0736 41.5 41.5 41.5V52.75C35.2868 52.75 30.25 57.7868 30.25 64C30.25 70.2132 35.2868 75.25 41.5 75.25V86.5Z" fill="white"/>
<path d="M86.5 86.5C86.5 98.9264 76.4264 109 64 109C51.5736 109 41.5 98.9264 41.5 86.5H52.75C52.75 92.7132 57.7868 97.75 64 97.75C70.2132 97.75 75.25 92.7132 75.25 86.5H86.5Z" fill="white"/>
<path d="M86.5 86.5C98.9264 86.5 109 76.4264 109 64C109 51.5736 98.9264 41.5 86.5 41.5V52.75C92.7132 52.75 97.75 57.7868 97.75 64C97.75 70.2132 92.7132 75.25 86.5 75.25V86.5Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64 86.5C76.4264 86.5 86.5 76.4264 86.5 64C86.5 51.5736 76.4264 41.5 64 41.5C51.5736 41.5 41.5 51.5736 41.5 64C41.5 76.4264 51.5736 86.5 64 86.5ZM64 75.25C70.2132 75.25 75.25 70.2132 75.25 64C75.25 57.7868 70.2132 52.75 64 52.75C57.7868 52.75 52.75 57.7868 52.75 64C52.75 70.2132 57.7868 75.25 64 75.25Z" fill="white"/>
<path d="M30.25 41.5C30.25 35.2868 35.2868 30.25 41.5 30.25V19C29.0736 19 19 29.0736 19 41.5H30.25Z" fill="white"/>
<path d="M97.75 41.5C97.75 35.2868 92.7132 30.25 86.5 30.25V19C98.9264 19 109 29.0736 109 41.5H97.75Z" fill="white"/>
<path d="M97.75 86.5C97.75 92.7132 92.7132 97.75 86.5 97.75V109C98.9264 109 109 98.9264 109 86.5H97.75Z" fill="white"/>
<path d="M30.25 86.5C30.25 92.7132 35.2868 97.75 41.5 97.75V109C29.0736 109 19 98.9264 19 86.5H30.25Z" fill="white"/>
<path d="M64 49.7864C64 47.4488 64.5643 45.2429 65.5642 43.2976L65.5645 43.2971L65.5711 43.2843C66.0791 42.2986 66.6989 41.3799 67.4139 40.5452C68.8566 38.8609 70.6864 37.518 72.7654 36.6545C72.5386 36.3807 72.2969 36.1151 72.0404 35.8586C70.7047 34.5229 69.1194 33.589 67.4346 33.0568C66.4711 33.6789 65.5647 34.382 64.7252 35.1564C64.6766 35.2012 64.6282 35.2463 64.5801 35.2916C64.3829 35.4771 64.1895 35.6667 64 35.86C63.8105 35.6667 63.6171 35.4771 63.4199 35.2916C62.54 34.4636 61.5846 33.7149 60.5653 33.0568C59.5394 32.3945 58.4487 31.8239 57.3048 31.3568C55.0096 30.4195 52.5001 29.8985 49.8702 29.8876C49.8423 29.8875 49.8144 29.8875 49.7864 29.8875C48.4122 29.8875 47.0706 30.0267 45.7748 30.292C38.0036 31.8827 31.8827 38.0036 30.292 45.7748C32.6037 44.0448 35.1703 42.8117 37.8441 42.0757C38.9389 40.3836 40.3836 38.9389 42.0757 37.8442C43.173 37.1342 44.3744 36.5713 45.652 36.1836C46.9602 35.7864 48.3484 35.5729 49.7864 35.5729C50.6505 35.5729 51.4966 35.65 52.3182 35.7977C53.3317 35.9799 54.3079 36.2696 55.2346 36.6545C57.3136 37.518 59.1434 38.8609 60.586 40.5452C61.3042 41.3837 61.9265 42.3069 62.4358 43.2976C62.4924 43.4079 62.5477 43.5189 62.6015 43.6308C63.4978 45.4933 64 47.5812 64 49.7864Z" fill="#FAFAFA"/>
<path d="M74.0505 53.9495C75.7035 52.2965 77.6623 51.1358 79.7449 50.4673C79.8233 50.4421 79.9019 50.4176 79.9807 50.3938C80.9672 50.096 81.9797 49.9077 82.9991 49.8289C85.2102 49.6581 87.4536 50.0024 89.5343 50.8619C89.5675 50.5079 89.5844 50.1492 89.5844 49.7864C89.5844 47.8975 89.1238 46.1161 88.3088 44.5485C87.1735 44.3041 86.021 44.1598 84.8654 44.1156C84.8136 44.1136 84.7619 44.1119 84.7101 44.1103C84.4395 44.102 84.1687 44.0993 83.898 44.102C83.9007 43.8313 83.898 43.5605 83.8897 43.2899C83.853 42.0823 83.7069 40.8773 83.4515 39.6912C83.1944 38.4974 82.8266 37.3228 82.348 36.1836C81.3878 33.8978 79.9817 31.7549 78.1298 29.8876C78.1102 29.8678 78.0905 29.848 78.0707 29.8283C77.099 28.8566 76.0518 28.0064 74.948 27.2777C68.3281 22.9074 59.6719 22.9074 53.052 27.2777C55.9099 27.689 58.5967 28.6319 61.0079 30.0022C62.9785 29.5798 65.0215 29.5798 66.9921 30.0022C68.2701 30.2761 69.5176 30.7276 70.6952 31.3568C71.9011 32.0011 73.0337 32.8316 74.0505 33.8485C74.6615 34.4595 75.2053 35.1123 75.6818 35.7977C76.2696 36.6432 76.755 37.5383 77.1381 38.4657C77.9976 40.5464 78.3419 42.7898 78.171 45.0009C78.086 46.1017 77.8732 47.1945 77.5327 48.2552C77.4948 48.3732 77.4554 48.4908 77.4143 48.608C76.7311 50.5587 75.6098 52.3902 74.0505 53.9495Z" fill="#FAFAFA"/>
<path d="M78.2135 64C80.5512 64 82.7571 64.5643 84.7023 65.5642L84.7157 65.5711C85.7014 66.0791 86.6201 66.699 87.4548 67.414C89.1391 68.8566 90.482 70.6864 91.3455 72.7654C91.6193 72.5386 91.8849 72.2969 92.1414 72.0404C93.4771 70.7047 94.411 69.1194 94.9432 67.4347C94.2851 66.4154 93.5363 65.46 92.7084 64.5801C92.5228 64.3829 92.3333 64.1895 92.1399 64C92.3333 63.8105 92.5228 63.6171 92.7084 63.4199C93.5363 62.54 94.2851 61.5846 94.9432 60.5653C95.6055 59.5394 96.1761 58.4487 96.6432 57.3048C97.5805 55.0095 98.1015 52.5 98.1124 49.8702C98.1125 49.8423 98.1125 49.8144 98.1125 49.7864C98.1125 48.4122 97.9732 47.0706 97.708 45.7748C96.1172 38.0036 89.9964 31.8828 82.2252 30.292C83.9552 32.6037 85.1883 35.1703 85.9243 37.8442C87.6164 38.939 89.061 40.3836 90.1558 42.0757C90.8658 43.173 91.4286 44.3744 91.8164 45.652C92.2136 46.9602 92.4271 48.3484 92.4271 49.7864C92.4271 50.6505 92.35 51.4966 92.2023 52.3182C92.0201 53.3317 91.7304 54.3079 91.3455 55.2346C90.482 57.3136 89.1391 59.1434 87.4548 60.586C86.6163 61.3043 85.6931 61.9265 84.7023 62.4358C84.5921 62.4924 84.481 62.5477 84.3691 62.6015C82.5067 63.4978 80.4187 64 78.2135 64Z" fill="#FAFAFA"/>
<path d="M74.0505 74.0505C75.7035 75.7035 76.8642 77.6623 77.5327 79.7449C77.5579 79.8233 77.5824 79.9019 77.6061 79.9807C77.904 80.9672 78.0923 81.9797 78.171 82.9991C78.3419 85.2102 77.9976 87.4536 77.1381 89.5343C77.4921 89.5675 77.8508 89.5844 78.2135 89.5844C80.1025 89.5844 81.8839 89.1238 83.4515 88.3088C83.6959 87.1735 83.8402 86.021 83.8844 84.8654C83.8864 84.8136 83.8881 84.7619 83.8897 84.7101C83.898 84.4395 83.9007 84.1687 83.898 83.898C84.1687 83.9007 84.4395 83.898 84.7101 83.8897C85.9177 83.853 87.1227 83.7069 88.3088 83.4515C89.5025 83.1944 90.6772 82.8266 91.8164 82.348C94.1022 81.3878 96.2451 79.9818 98.1124 78.1298C98.1322 78.1102 98.152 78.0905 98.1717 78.0707C99.1434 77.099 99.9936 76.0518 100.722 74.948C105.093 68.3281 105.093 59.6719 100.722 53.052C100.311 55.9099 99.368 58.5967 97.9978 61.0078C98.4202 62.9785 98.4202 65.0215 97.9978 66.9922C97.7239 68.2701 97.2724 69.5176 96.6432 70.6952C95.9989 71.9011 95.1684 73.0337 94.1515 74.0505C93.5405 74.6615 92.8877 75.2053 92.2023 75.6818C91.3568 76.2696 90.4617 76.755 89.5343 77.1381C87.4536 77.9976 85.2102 78.3419 82.9991 78.171C81.8983 78.086 80.8055 77.8732 79.7449 77.5327C79.6268 77.4948 79.5092 77.4554 79.392 77.4143C77.4413 76.7311 75.6098 75.6098 74.0505 74.0505Z" fill="#FAFAFA"/>
<path d="M64 78.2136C64 80.5512 63.4356 82.7571 62.4358 84.7024L62.4289 84.7157C61.9209 85.7014 61.301 86.6201 60.586 87.4548C59.1434 89.1391 57.3136 90.482 55.2346 91.3455C55.4614 91.6193 55.7031 91.8849 55.9596 92.1414C57.2953 93.4771 58.8806 94.411 60.5653 94.9432C61.5791 94.2886 62.5296 93.5445 63.4056 92.7219L63.4199 92.7084C63.6171 92.5229 63.8105 92.3333 64 92.14C64.1895 92.3333 64.3829 92.5229 64.5801 92.7084C65.46 93.5364 66.4153 94.2851 67.4347 94.9432C68.4605 95.6055 69.5512 96.1761 70.6952 96.6432C72.9904 97.5805 75.4999 98.1015 78.1298 98.1124C78.1577 98.1125 78.1856 98.1126 78.2135 98.1126C79.5877 98.1126 80.9294 97.9733 82.2252 97.708C89.9964 96.1172 96.1172 89.9964 97.708 82.2252C95.3963 83.9552 92.8297 85.1883 90.1558 85.9243C89.061 87.6164 87.6164 89.061 85.9243 90.1558C84.827 90.8658 83.6256 91.4286 82.348 91.8164C81.0397 92.2136 79.6516 92.4271 78.2135 92.4271C77.3494 92.4271 76.5034 92.35 75.6818 92.2023C74.6683 92.0201 73.6921 91.7304 72.7654 91.3455C70.6864 90.482 68.8566 89.1391 67.4139 87.4548C66.6957 86.6163 66.0735 85.6931 65.5642 84.7024C65.5075 84.5921 65.4523 84.4811 65.3984 84.3692C64.5022 82.5067 64 80.4188 64 78.2136Z" fill="#FAFAFA"/>
<path d="M53.9495 74.0505C52.2965 75.7035 50.3377 76.8642 48.2551 77.5327C48.1767 77.5579 48.0981 77.5824 48.0193 77.6061C47.0328 77.904 46.0203 78.0923 45.0009 78.171C42.7898 78.3419 40.5464 77.9976 38.4657 77.1381C38.4325 77.4921 38.4156 77.8508 38.4156 78.2136C38.4156 80.1025 38.8762 81.8839 39.6912 83.4515C40.8773 83.7069 42.0822 83.853 43.2899 83.8897C43.5605 83.898 43.8313 83.9007 44.102 83.898C44.0993 84.1687 44.102 84.4395 44.1103 84.7101C44.147 85.9178 44.2931 87.1227 44.5485 88.3088C44.8056 89.5026 45.1734 90.6773 45.652 91.8164C46.6122 94.1022 48.0183 96.2451 49.8702 98.1124C49.8898 98.1322 49.9095 98.152 49.9293 98.1717C50.901 99.1434 51.9482 99.9936 53.052 100.722C59.6719 105.093 68.3281 105.093 74.948 100.722C72.0901 100.311 69.4033 99.3681 66.9921 97.9978C65.0215 98.4202 62.9785 98.4202 61.0078 97.9978C59.7299 97.7239 58.4824 97.2724 57.3048 96.6432C56.0989 95.9989 54.9663 95.1684 53.9495 94.1515C53.3385 93.5405 52.7947 92.8877 52.3182 92.2023C51.7304 91.3568 51.245 90.4617 50.8619 89.5343C50.0024 87.4536 49.6581 85.2102 49.829 82.9991C49.914 81.8983 50.1268 80.8055 50.4673 79.7449C50.5052 79.6268 50.5446 79.5092 50.5857 79.392C51.2689 77.4413 52.3902 75.6098 53.9495 74.0505Z" fill="#FAFAFA"/>
<path d="M40.5451 67.414C41.3837 66.6957 42.3068 66.0735 43.2976 65.5642C43.4079 65.5076 43.5189 65.4523 43.6308 65.3985C45.4933 64.5022 47.5812 64 49.7864 64C47.4487 64 45.2429 63.4357 43.2976 62.4358L43.2971 62.4355L43.2843 62.4289C42.2986 61.9209 41.3799 61.301 40.5451 60.586C38.8608 59.1434 37.518 57.3136 36.6544 55.2346C36.3807 55.4614 36.1151 55.7031 35.8586 55.9596C34.5229 57.2953 33.589 58.8806 33.0568 60.5654C33.511 61.2689 34.0084 61.9419 34.5452 62.5807C34.7861 62.8674 35.0351 63.1473 35.2916 63.4199C35.4771 63.6171 35.6666 63.8105 35.86 64C35.6666 64.1895 35.4771 64.3829 35.2916 64.5801C34.4636 65.46 33.7149 66.4153 33.0568 67.4346C32.3945 68.4605 31.8239 69.5512 31.3568 70.6951C30.4194 72.9904 29.8984 75.4999 29.8876 78.1298C29.8875 78.1577 29.8874 78.1856 29.8874 78.2136C29.8874 79.5878 30.0267 80.9294 30.292 82.2252C31.8827 89.9964 38.0036 96.1173 45.7748 97.708C44.0448 95.3963 42.8117 92.8297 42.0757 90.1558C40.3836 89.0611 38.9389 87.6164 37.8441 85.9243C37.1342 84.827 36.5713 83.6256 36.1835 82.348C35.7864 81.0397 35.5729 79.6516 35.5729 78.2136C35.5729 77.3494 35.65 76.5034 35.7977 75.6818C35.9799 74.6683 36.2696 73.6921 36.6544 72.7654C37.518 70.6864 38.8608 68.8566 40.5451 67.414Z" fill="#FAFAFA"/>
<path d="M45.0009 49.8289C46.1017 49.914 47.1945 50.1268 48.2551 50.4673C48.3732 50.5052 48.4908 50.5446 48.608 50.5857C50.5587 51.2689 52.3902 52.3902 53.9495 53.9495C52.2965 52.2965 51.1358 50.3377 50.4673 48.2552C50.4421 48.1767 50.4176 48.0981 50.3938 48.0193C50.096 47.0328 49.9077 46.0203 49.829 45.0009C49.6581 42.7898 50.0024 40.5464 50.8619 38.4657C50.5079 38.4325 50.1491 38.4156 49.7864 38.4156C47.8975 38.4156 46.1161 38.8762 44.5485 39.6912C44.2931 40.8773 44.147 42.0823 44.1103 43.2899C44.102 43.5605 44.0993 43.8313 44.102 44.102C43.8313 44.0993 43.5605 44.102 43.2899 44.1103C42.0822 44.147 40.8773 44.2931 39.6912 44.5485C38.4974 44.8056 37.3227 45.1734 36.1835 45.652C33.8978 46.6122 31.7549 48.0183 29.8876 49.8702C29.8678 49.8899 29.848 49.9095 29.8283 49.9293C28.8566 50.901 28.0064 51.9482 27.2777 53.052C22.9074 59.6719 22.9074 68.3281 27.2777 74.948C27.689 72.0901 28.6319 69.4033 30.0022 66.9921C29.5798 65.0215 29.5798 62.9785 30.0022 61.0079C30.2761 59.7299 30.7276 58.4824 31.3568 57.3048C32.0011 56.0989 32.8316 54.9663 33.8485 53.9495C34.4595 53.3385 35.1123 52.7947 35.7977 52.3182C36.6432 51.7304 37.5383 51.245 38.4657 50.8619C40.5464 50.0024 42.7898 49.6581 45.0009 49.8289Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
public/logo-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
public/logo-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -12,6 +12,7 @@ import { Categories } from '@/components/categories';
import { SharedModal } from '@/components/modals/shared';
import { Toolbar } from '@/components/toolbar';
import { SnackbarProvider } from '@/contexts/snackbar';
import { MediaControls } from '@/components/media-controls';
import { sounds } from '@/data/sounds';
import { FADE_OUT } from '@/constants/events';
@@ -19,6 +20,77 @@ import { FADE_OUT } from '@/constants/events';
import type { Sound } from '@/data/types';
import { subscribe } from '@/lib/event';
/**
* =========================================
*/
declare global {
interface Window {
__howlerStreamPatched?: boolean;
}
}
/**
* Patches Howler's master gain node to route its output into a hidden HTML audio element.
* An intermediate splitter node is used in an attempt to reduce the banging noise observed on iOS.
* Also adds a listener to resume the AudioContext when the document becomes visible.
*/
export function setupAudioStream(): void {
if (
typeof window !== 'undefined' &&
Howler.ctx &&
!window.__howlerStreamPatched
) {
const audioCtx = Howler.ctx;
// Create a MediaStream destination node to capture the output.
const streamDestination = audioCtx.createMediaStreamDestination();
// Create a splitter gain node to help split the signal cleanly.
const splitter = audioCtx.createGain();
// Disconnect the master gain.
Howler.masterGain.disconnect();
// Reconnect masterGain: one branch to the AudioContext's default destination,
// and one branch through the splitter to the MediaStream destination.
Howler.masterGain.connect(audioCtx.destination);
Howler.masterGain.connect(splitter);
splitter.connect(streamDestination);
// Create a hidden HTML audio element to play the captured stream.
const audioElement = document.createElement('audio');
audioElement.setAttribute('playsinline', 'true'); // crucial for iOS playback
audioElement.srcObject = streamDestination.stream;
audioElement.style.display = 'none';
document.body.appendChild(audioElement);
// Attempt to start playback (must be triggered by a user gesture).
audioElement.play().catch((err: unknown) => {
console.error('Failed to play background stream:', err);
});
// Listen for visibility changes: if the document becomes visible and the AudioContext is suspended, resume it.
document.addEventListener('visibilitychange', () => {
if (
document.visibilityState === 'visible' &&
audioCtx.state === 'suspended'
) {
audioCtx
.resume()
.catch((err: unknown) =>
console.error('Error resuming AudioContext:', err),
);
}
});
window.__howlerStreamPatched = true;
}
}
/**
* =========================================
*/
export function App() {
const categories = useMemo(() => sounds.categories, []);
@@ -85,9 +157,23 @@ export function App() {
return [...favorites, ...categories];
}, [favoriteSounds, categories]);
useEffect(() => {
const handleUserInteraction = () => {
setupAudioStream();
document.removeEventListener('click', handleUserInteraction);
};
document.addEventListener('click', handleUserInteraction);
return () => {
document.removeEventListener('click', handleUserInteraction);
};
}, []);
return (
<SnackbarProvider>
<StoreConsumer>
<MediaControls />
<Container>
<div id="app" />
<Buttons />

17
src/components/binary.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
import { generateRandomBinaryString } from '@/helpers/binary';
export function Binary() {
const [binary, setBinary] = useState('');
useEffect(() => {
setBinary(generateRandomBinaryString(1000));
setInterval(() => {
setBinary(generateRandomBinaryString(1000));
}, 200);
}, []);
return <span>{binary}</span>;
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect } from 'react';
import { BiPause, BiPlay } from 'react-icons/bi/index';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSoundStore } from '@/stores/sound';
import { useSnackbar } from '@/contexts/snackbar';
@@ -28,17 +29,7 @@ export function PlayButton() {
if (isPlaying && noSelected) pause();
}, [isPlaying, pause, noSelected]);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (e.shiftKey && e.key === ' ') {
handleToggle();
}
};
document.addEventListener('keydown', listener);
return () => document.removeEventListener('keydown', listener);
}, [handleToggle]);
useHotkeys('shift+space', handleToggle, {}, [handleToggle]);
return (
<button

View File

@@ -1,6 +1,7 @@
import { useEffect, useCallback } from 'react';
import { useCallback } from 'react';
import { BiUndo, BiTrash } from 'react-icons/bi/index';
import { AnimatePresence, motion } from 'framer-motion';
import { useHotkeys } from 'react-hotkeys-hook';
import { Tooltip } from '@/components/tooltip';
@@ -28,17 +29,7 @@ export function UnselectButton() {
else if (!noSelected) unselectAll(true);
}, [hasHistory, noSelected, unselectAll, restoreHistory, locked]);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (e.shiftKey && e.key === 'R') {
handleToggle();
}
};
document.addEventListener('keydown', listener);
return () => document.removeEventListener('keydown', listener);
}, [handleToggle]);
useHotkeys('shift+r', handleToggle, {}, [handleToggle]);
return (
<>

View File

@@ -0,0 +1,23 @@
.checkboxRoot {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
cursor: pointer;
background: var(--color-neutral-100);
border: 2px solid var(--color-neutral-300);
border-radius: 4px;
transition: 0.2s;
}
.checkboxRoot[data-state='checked'] {
background: var(--color-neutral-950);
border: 2px solid var(--color-neutral-950);
}
.checkboxIndicator {
font-size: var(--font-2xsm);
color: var(--color-neutral-50);
transform: translateY(2px);
}

View File

@@ -0,0 +1,38 @@
import * as RadixCheckbox from '@radix-ui/react-checkbox';
import { FaCheck } from 'react-icons/fa6/index';
import styles from './checkbox.module.css';
type CheckboxInputProps = {
checked?: boolean;
className?: string;
defaultChecked?: boolean;
disabled?: boolean;
onChange?: (checked: boolean) => void;
};
export function Checkbox({
checked,
className,
defaultChecked = false,
disabled = false,
onChange,
}: CheckboxInputProps) {
const handleCheckedChange = (checked: boolean) => {
if (onChange) onChange(checked);
};
return (
<RadixCheckbox.Root
checked={checked}
className={`${styles.checkboxRoot} ${className}`}
defaultChecked={defaultChecked}
disabled={disabled}
onCheckedChange={handleCheckedChange}
>
<RadixCheckbox.Indicator className={styles.checkboxIndicator}>
<FaCheck />
</RadixCheckbox.Indicator>
</RadixCheckbox.Root>
);
}

View File

@@ -0,0 +1 @@
export { Checkbox } from './checkbox';

59
src/components/cipher.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { useState, useEffect } from 'react';
interface CipherTextProps {
interval?: number;
text: string;
}
const chars = '-_~`!@#$%^&*()+=[]{}|;:,.<>?';
export function CipherText({ interval = 50, text }: CipherTextProps) {
const [outputText, setOutputText] = useState('');
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
let timer: NodeJS.Timeout;
if (outputText !== text) {
timer = setInterval(() => {
if (outputText.length < text.length) {
setOutputText(prev => prev + text[prev.length]);
} else {
clearInterval(timer);
}
}, interval);
}
return () => clearInterval(timer);
}, [text, interval, outputText]);
useEffect(() => {
if (outputText === text) {
setTimeout(() => setOutputText(''), 6000);
}
}, [outputText, text]);
const remainder =
outputText.length < text.length
? text
.slice(outputText.length)
.split('')
.map(() => chars[Math.floor(Math.random() * chars.length)])
.join('')
: '';
if (!isMounted) {
return <span>{text}</span>;
}
return (
<span className="text-white">
{outputText}
{remainder}
</span>
);
}

View File

@@ -2,6 +2,7 @@
import { BsSoundwave } from 'react-icons/bs/index';
import { Container } from './container';
import { CipherText } from './cipher';
import { count as soundCount } from '@/lib/sounds';
@@ -21,8 +22,12 @@ const count = soundCount();
width={45}
/>
<h2 class="title"><span>Moodist</span></h2>
<h1 class="desc">Ambient sounds for focus and calm.</h1>
<h1 class="title">
Ambient Sounds<span class="line">For Focus and Calm</span>
</h1>
<h2 class="desc">
Free and <CipherText client:load text="Open-Source" />.
</h2>
<p class="sounds">
<span aria-hidden="true" class="icon">
@@ -72,11 +77,11 @@ const count = soundCount();
& .title {
font-family: var(--font-display);
font-size: var(--font-2xlg);
font-size: var(--font-xlg);
font-weight: 600;
line-height: 1;
& span {
/* & .gradient {
background: linear-gradient(
135deg,
var(--color-foreground),
@@ -84,11 +89,22 @@ const count = soundCount();
);
background-clip: text;
-webkit-text-fill-color: transparent;
} */
& .line {
display: block;
margin-top: 2px;
background: linear-gradient(
var(--color-foreground-subtler),
var(--color-foreground-subtle)
);
background-clip: text;
-webkit-text-fill-color: transparent;
}
}
& .desc {
margin-top: 4px;
margin-top: 12px;
line-height: 1.6;
color: var(--color-foreground-subtle);
}

View File

@@ -0,0 +1 @@
export { MediaControls } from './media-controls';

View File

@@ -0,0 +1,20 @@
import { MediaSessionTrack } from './media-session-track';
import { useEffect, useState } from 'react';
import { useSSR } from '@/hooks/use-ssr';
export function MediaControls() {
const [mediaControlsEnabled, setMediaControlsEnabled] = useState(false);
const { isBrowser } = useSSR();
useEffect(() => {
if (!isBrowser) return;
setMediaControlsEnabled('mediaSession' in navigator);
}, [isBrowser]);
if (!mediaControlsEnabled) {
return null;
}
return <MediaSessionTrack />;
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getSilenceDataURL } from '@/helpers/sound';
import { BrowserDetect } from '@/helpers/browser-detect';
import { useSoundStore } from '@/stores/sound';
import { useSSR } from '@/hooks/use-ssr';
import { useDarkTheme } from '@/hooks/use-dark-theme';
const metadata: MediaMetadataInit = {
artist: 'Moodist',
title: 'Ambient Sounds for Focus and Calm',
};
export function MediaSessionTrack() {
const { isBrowser } = useSSR();
const isDarkTheme = useDarkTheme();
const [isGenerated, setIsGenerated] = useState(false);
const isPlaying = useSoundStore(state => state.isPlaying);
const play = useSoundStore(state => state.play);
const pause = useSoundStore(state => state.pause);
const masterAudioSoundRef = useRef<HTMLAudioElement>(null);
const artworkURL = isDarkTheme ? '/logo-dark.png' : '/logo-light.png';
const generateSilence = useCallback(async () => {
if (!masterAudioSoundRef.current) return;
masterAudioSoundRef.current.src = await getSilenceDataURL();
setIsGenerated(true);
}, []);
useEffect(() => {
if (!isBrowser || !isPlaying || !isGenerated) return;
navigator.mediaSession.metadata = new MediaMetadata({
...metadata,
artwork: [
{
sizes: '200x200',
src: artworkURL,
type: 'image/png',
},
],
});
}, [artworkURL, isBrowser, isDarkTheme, isGenerated, isPlaying]);
useEffect(() => {
generateSilence();
}, [generateSilence]);
const startMasterAudio = useCallback(async () => {
if (!masterAudioSoundRef.current) return;
if (!masterAudioSoundRef.current.paused) return;
try {
await masterAudioSoundRef.current.play();
navigator.mediaSession.playbackState = 'playing';
navigator.mediaSession.setActionHandler('play', play);
navigator.mediaSession.setActionHandler('pause', pause);
} catch {
// Do nothing
}
}, [pause, play]);
const stopMasterAudio = useCallback(() => {
if (!masterAudioSoundRef.current) return;
/**
* Otherwise in Safari we cannot play the audio again
* through the media session controls
*/
if (BrowserDetect.isSafari()) {
masterAudioSoundRef.current.load();
} else {
masterAudioSoundRef.current.pause();
}
navigator.mediaSession.playbackState = 'paused';
}, []);
useEffect(() => {
if (!isGenerated) return;
if (!masterAudioSoundRef.current) return;
if (isPlaying) {
startMasterAudio();
} else {
stopMasterAudio();
}
}, [isGenerated, isPlaying, startMasterAudio, stopMasterAudio]);
useEffect(() => {
const masterAudioSound = masterAudioSoundRef.current;
return () => {
masterAudioSound?.pause();
navigator.mediaSession.setActionHandler('play', null);
navigator.mediaSession.setActionHandler('pause', null);
navigator.mediaSession.playbackState = 'none';
};
}, []);
return <audio id="media-session-track" loop ref={masterAudioSoundRef} />;
}

View File

@@ -0,0 +1,76 @@
.header {
margin-bottom: 16px;
& .title {
margin-bottom: 4px;
font-family: var(--font-heading);
font-size: var(--font-md);
font-weight: 600;
}
& .desc {
color: var(--color-foreground-subtle);
}
}
.fieldWrapper {
margin-bottom: 12px;
& label {
display: block;
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-foreground);
& input,
select {
display: block;
width: 100%;
min-width: 0;
height: 45px;
padding: 0 8px;
margin-top: 4px;
color: var(--color-foreground);
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
outline: none;
}
& .volume {
margin-top: 4px;
}
}
}
.buttons {
display: flex;
column-gap: 8px;
align-items: center;
margin-top: 12px;
& button {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: center;
height: 45px;
font-weight: 500;
color: var(--color-foreground);
cursor: pointer;
background-color: var(--color-neutral-200);
border: none;
border-radius: 8px;
outline: none;
&:disabled {
cursor: not-allowed;
opacity: 0.3;
}
&.primary {
color: var(--color-neutral-50);
background-color: var(--color-neutral-950);
}
}
}

View File

@@ -0,0 +1,223 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { Modal } from '@/components/modal';
import { Slider } from '@/components/slider';
import styles from './binaural.module.css';
interface BinauralProps {
onClose: () => void;
show: boolean;
}
interface Preset {
baseFrequency: number;
beatFrequency: number;
name: string;
}
const presets: Preset[] = [
{ baseFrequency: 100, beatFrequency: 2, name: 'Delta (Deep Sleep) 2 Hz' },
{ baseFrequency: 100, beatFrequency: 5, name: 'Theta (Meditation) 5 Hz' },
{ baseFrequency: 100, beatFrequency: 10, name: 'Alpha (Relaxation) 10 Hz' },
{ baseFrequency: 100, beatFrequency: 20, name: 'Beta (Focus) 20 Hz' },
{ baseFrequency: 100, beatFrequency: 40, name: 'Gamma (Cognition) 40 Hz' },
{ baseFrequency: 440, beatFrequency: 10, name: 'Custom' },
];
export function BinauralModal({ onClose, show }: BinauralProps) {
const [baseFrequency, setBaseFrequency] = useState<number>(440); // Default to A4 note
const [beatFrequency, setBeatFrequency] = useState<number>(10); // Default to 10 Hz difference
const [volume, setVolume] = useState<number>(0.5); // Default volume at 50%
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [selectedPreset, setSelectedPreset] = useState<string>('Custom');
const audioContextRef = useRef<AudioContext | null>(null);
const leftOscillatorRef = useRef<OscillatorNode | null>(null);
const rightOscillatorRef = useRef<OscillatorNode | null>(null);
const gainNodeRef = useRef<GainNode | null>(null);
const startSound = () => {
if (isPlaying) return;
// Initialize the AudioContext
audioContextRef.current = new window.AudioContext();
const audioContext = audioContextRef.current;
if (!audioContext) return;
// Create a gain node for volume control
gainNodeRef.current = audioContext.createGain();
gainNodeRef.current.gain.value = volume; // Set volume based on state
// Create oscillators for left and right channels
leftOscillatorRef.current = audioContext.createOscillator();
rightOscillatorRef.current = audioContext.createOscillator();
if (
!leftOscillatorRef.current ||
!rightOscillatorRef.current ||
!gainNodeRef.current
)
return;
leftOscillatorRef.current.frequency.value =
baseFrequency - beatFrequency / 2;
rightOscillatorRef.current.frequency.value =
baseFrequency + beatFrequency / 2;
// Pan oscillators to left and right
const leftPanner = audioContext.createStereoPanner();
leftPanner.pan.value = -1;
const rightPanner = audioContext.createStereoPanner();
rightPanner.pan.value = 1;
// Connect nodes
leftOscillatorRef.current.connect(leftPanner).connect(gainNodeRef.current);
rightOscillatorRef.current
.connect(rightPanner)
.connect(gainNodeRef.current);
gainNodeRef.current.connect(audioContext.destination);
// Start oscillators
leftOscillatorRef.current.start();
rightOscillatorRef.current.start();
setIsPlaying(true);
};
const stopSound = useCallback(() => {
if (!isPlaying) return;
leftOscillatorRef.current?.stop();
rightOscillatorRef.current?.stop();
audioContextRef.current?.close();
setIsPlaying(false);
}, [isPlaying]);
useEffect(() => {
// Update gain node when volume changes
if (gainNodeRef.current) {
gainNodeRef.current.gain.value = volume;
}
}, [volume]);
useEffect(() => {
// Cleanup when component unmounts
return () => {
if (isPlaying) {
stopSound();
}
};
}, [isPlaying, stopSound]);
useEffect(() => {
// Update frequencies when a preset is selected
if (selectedPreset !== 'Custom') {
const preset = presets.find(p => p.name === selectedPreset);
if (preset) {
setBaseFrequency(preset.baseFrequency);
setBeatFrequency(preset.beatFrequency);
}
}
}, [selectedPreset]);
const handlePresetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selected = e.target.value;
setSelectedPreset(selected);
if (selected === 'Custom') {
// Allow user to input custom frequencies
return;
}
const preset = presets.find(p => p.name === selected);
if (preset) {
setBaseFrequency(preset.baseFrequency);
setBeatFrequency(preset.beatFrequency);
}
};
return (
<Modal show={show} onClose={onClose}>
<header className={styles.header}>
<h2 className={styles.title}>Binaural Beat</h2>
<p className={styles.desc}>Binaural beat generator.</p>
</header>
<div className={styles.fieldWrapper}>
<label>
Presets:
<select value={selectedPreset} onChange={handlePresetChange}>
{presets.map(preset => (
<option key={preset.name} value={preset.name}>
{preset.name}
</option>
))}
</select>
</label>
</div>
{selectedPreset === 'Custom' && (
<>
<div className={styles.fieldWrapper}>
<label>
Base Frequency (Hz):
<input
max="1500"
min="20"
step="0.1"
type="number"
value={baseFrequency}
onChange={e =>
setBaseFrequency(parseFloat(e.target.value || '0'))
}
/>
</label>
</div>
<div className={styles.fieldWrapper}>
<label>
Beat Frequency (Hz):
<input
max="40"
min="0.1"
step="0.1"
type="number"
value={beatFrequency}
onChange={e =>
setBeatFrequency(parseFloat(e.target.value || '0'))
}
/>
</label>
</div>
</>
)}
<div className={styles.fieldWrapper}>
<label>
Volume:
<Slider
className={styles.volume}
max={1}
min={0}
step={0.01}
value={volume}
onChange={value => setVolume(value)}
/>
</label>
</div>
<div className={styles.buttons}>
<button
className={styles.primary}
disabled={isPlaying}
onClick={startSound}
>
Start
</button>
<button disabled={!isPlaying} onClick={stopSound}>
Stop
</button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1 @@
export { BinauralModal } from './binaural';

View File

@@ -0,0 +1 @@
export { IsochronicModal } from './isochronic';

View File

@@ -0,0 +1,76 @@
.header {
margin-bottom: 16px;
& .title {
margin-bottom: 4px;
font-family: var(--font-heading);
font-size: var(--font-md);
font-weight: 600;
}
& .desc {
color: var(--color-foreground-subtle);
}
}
.fieldWrapper {
margin-bottom: 12px;
& label {
display: block;
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-foreground);
& input,
select {
display: block;
width: 100%;
min-width: 0;
height: 45px;
padding: 0 8px;
margin-top: 4px;
color: var(--color-foreground);
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
outline: none;
}
& .volume {
margin-top: 4px;
}
}
}
.buttons {
display: flex;
column-gap: 8px;
align-items: center;
margin-top: 12px;
& button {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: center;
height: 45px;
font-weight: 500;
color: var(--color-foreground);
cursor: pointer;
background-color: var(--color-neutral-200);
border: none;
border-radius: 8px;
outline: none;
&:disabled {
cursor: not-allowed;
opacity: 0.3;
}
&.primary {
color: var(--color-neutral-50);
background-color: var(--color-neutral-950);
}
}
}

View File

@@ -0,0 +1,258 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { Modal } from '@/components/modal';
import { Slider } from '@/components/slider';
import styles from './isochornic.module.css';
interface IsochronicProps {
onClose: () => void;
show: boolean;
}
interface Preset {
baseFrequency: number;
beatFrequency: number;
name: string;
}
const presets: Preset[] = [
{ baseFrequency: 100, beatFrequency: 2, name: 'Delta (Deep Sleep) 2 Hz' },
{ baseFrequency: 100, beatFrequency: 5, name: 'Theta (Meditation) 5 Hz' },
{ baseFrequency: 100, beatFrequency: 10, name: 'Alpha (Relaxation) 10 Hz' },
{ baseFrequency: 100, beatFrequency: 20, name: 'Beta (Focus) 20 Hz' },
{ baseFrequency: 100, beatFrequency: 40, name: 'Gamma (Cognition) 40 Hz' },
{ baseFrequency: 440, beatFrequency: 10, name: 'Custom' },
];
export function IsochronicModal({ onClose, show }: IsochronicProps) {
const [baseFrequency, setBaseFrequency] = useState<number>(440); // Default A4 note
const [beatFrequency, setBeatFrequency] = useState<number>(10); // Default 10 Hz beat
const [volume, setVolume] = useState<number>(0.5); // Default volume at 50%
const [waveform] = useState<OscillatorType>('sine'); // Default waveform
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [selectedPreset, setSelectedPreset] = useState<string>('Custom');
const audioContextRef = useRef<AudioContext | null>(null);
const oscillatorRef = useRef<OscillatorNode | null>(null);
const gainNodeRef = useRef<GainNode | null>(null);
const beatGainRef = useRef<GainNode | null>(null);
const modulatorRef = useRef<OscillatorNode | null>(null);
const startSound = () => {
if (isPlaying) return;
audioContextRef.current = new window.AudioContext();
const audioContext = audioContextRef.current;
if (!audioContext) return;
// Main gain node for volume control
gainNodeRef.current = audioContext.createGain();
gainNodeRef.current.gain.value = volume;
// Oscillator for the base tone
oscillatorRef.current = audioContext.createOscillator();
oscillatorRef.current.frequency.value = baseFrequency;
oscillatorRef.current.type = waveform;
// Gain node to create isochronic beats
beatGainRef.current = audioContext.createGain();
beatGainRef.current.gain.value = 0; // Start with silence
// Oscillator for modulation
modulatorRef.current = audioContext.createOscillator();
modulatorRef.current.frequency.value = beatFrequency;
modulatorRef.current.type = 'square'; // Square wave for on/off effect
// Modulator gain to adjust modulation depth
const modulatorGain = audioContext.createGain();
modulatorGain.gain.value = 0.5; // Modulation depth
// Connect modulator to the beat gain node
modulatorRef.current
.connect(modulatorGain)
.connect(beatGainRef.current.gain);
// Connect oscillator through beat gain and main gain to destination
oscillatorRef.current
.connect(beatGainRef.current)
.connect(gainNodeRef.current)
.connect(audioContext.destination);
// Start oscillators
oscillatorRef.current.start();
modulatorRef.current.start();
setIsPlaying(true);
};
const stopSound = useCallback(() => {
if (!isPlaying) return;
oscillatorRef.current?.stop();
modulatorRef.current?.stop();
audioContextRef.current?.close();
setIsPlaying(false);
}, [isPlaying]);
useEffect(() => {
// Update gain when volume changes
if (gainNodeRef.current) {
gainNodeRef.current.gain.value = volume;
}
}, [volume]);
useEffect(() => {
// Update base frequency when it changes
if (oscillatorRef.current) {
oscillatorRef.current.frequency.value = baseFrequency;
}
}, [baseFrequency]);
useEffect(() => {
// Update beat frequency when it changes
if (modulatorRef.current) {
modulatorRef.current.frequency.value = beatFrequency;
}
}, [beatFrequency]);
useEffect(() => {
// Update waveform when it changes
if (oscillatorRef.current) {
oscillatorRef.current.type = waveform;
}
}, [waveform]);
useEffect(() => {
// Cleanup when component unmounts
return () => {
if (isPlaying) {
stopSound();
}
};
}, [isPlaying, stopSound]);
useEffect(() => {
// Update frequencies when a preset is selected
if (selectedPreset !== 'Custom') {
const preset = presets.find(p => p.name === selectedPreset);
if (preset) {
setBaseFrequency(preset.baseFrequency);
setBeatFrequency(preset.beatFrequency);
}
}
}, [selectedPreset]);
const handlePresetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selected = e.target.value;
setSelectedPreset(selected);
if (selected === 'Custom') {
// Allow user to input custom frequencies
return;
}
const preset = presets.find(p => p.name === selected);
if (preset) {
setBaseFrequency(preset.baseFrequency);
setBeatFrequency(preset.beatFrequency);
}
};
return (
<Modal show={show} onClose={onClose}>
<header className={styles.header}>
<h2 className={styles.title}>Isochronic Tone</h2>
<p className={styles.desc}>Isochronic tone generator.</p>
</header>
<div className={styles.fieldWrapper}>
<label>
Presets:
<select value={selectedPreset} onChange={handlePresetChange}>
{presets.map(preset => (
<option key={preset.name} value={preset.name}>
{preset.name}
</option>
))}
</select>
</label>
</div>
{selectedPreset === 'Custom' && (
<>
<div className={styles.fieldWrapper}>
<label>
Base Frequency (Hz):
<input
max="2000"
min="20"
step="0.1"
type="number"
value={baseFrequency}
onChange={e =>
setBaseFrequency(parseFloat(e.target.value || '0'))
}
/>
</label>
</div>
<div className={styles.fieldWrapper}>
<label>
Tone Frequency (Hz):
<input
max="40"
min="0.1"
step="0.1"
type="number"
value={beatFrequency}
onChange={e =>
setBeatFrequency(parseFloat(e.target.value || '0'))
}
/>
</label>
</div>
{/* <div className={styles.fieldWrapper}>
<label>
Waveform:
<select
value={waveform}
onChange={e => setWaveform(e.target.value as OscillatorType)}
>
<option value="sine">Sine</option>
<option value="square">Square</option>
<option value="sawtooth">Sawtooth</option>
<option value="triangle">Triangle</option>
</select>
</label>
</div> */}
</>
)}
<div className={styles.fieldWrapper}>
<label>
Volume:
<Slider
className={styles.volume}
max={1}
min={0}
step={0.01}
value={volume}
onChange={value => setVolume(value)}
/>
</label>
</div>
<div className={styles.buttons}>
<button
className={styles.primary}
disabled={isPlaying}
onClick={startSound}
>
Start
</button>
<button disabled={!isPlaying} onClick={stopSound}>
Stop
</button>
</div>
</Modal>
);
}

View File

@@ -14,7 +14,7 @@ export function ShortcutsModal({ onClose, show }: ShortcutsModalProps) {
label: 'Shortcuts List',
},
{
keys: ['Shift', 'P'],
keys: ['Shift', 'Alt', 'P'],
label: 'Presets',
},
{
@@ -22,9 +22,29 @@ export function ShortcutsModal({ onClose, show }: ShortcutsModalProps) {
label: 'Share Sounds',
},
{
keys: ['Shift', 'T'],
keys: ['Shift', 'Alt', 'T'],
label: 'Sleep Timer',
},
{
keys: ['Shift', 'C'],
label: 'Countdown Timer',
},
{
keys: ['Shift', 'P'],
label: 'Pomodoro',
},
{
keys: ['Shift', 'N'],
label: 'Notepad',
},
{
keys: ['Shift', 'T'],
label: 'Todo Checklist',
},
{
keys: ['Shift', 'B'],
label: 'Breathing Exercise',
},
{
keys: ['Shift', 'Space'],
label: 'Toggle Play',

View File

@@ -0,0 +1 @@
export { Slider } from './slider';

View File

@@ -0,0 +1,42 @@
.sliderRoot {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: 20px;
touch-action: none;
}
.sliderTrack {
position: relative;
flex-grow: 1;
height: 4px;
background: var(--color-neutral-200);
border-radius: 9999px;
}
.sliderRange {
position: absolute;
height: 100%;
background: var(--color-neutral-800);
border-radius: 9999px;
}
.sliderThumb {
display: block;
width: 16px;
height: 16px;
cursor: pointer;
background: var(--color-neutral-950);
border-radius: 50%;
box-shadow: 0 0 3px var(--color-neutral-50);
}
.sliderThumb:hover {
background: var(--color-neutral-800);
}
.sliderThumb:focus {
outline: none;
box-shadow: 0 0 0 3px var(--color-neutral-400);
}

View File

@@ -0,0 +1,46 @@
import * as RadixSlider from '@radix-ui/react-slider';
import styles from './slider.module.css';
type SliderProps = {
className?: string;
defaultValue?: number;
disabled?: boolean;
max?: number;
min?: number;
onChange?: (value: number) => void;
step?: number;
value?: number;
};
export function Slider({
className,
defaultValue = 50,
disabled = false,
max = 100,
min = 0,
onChange,
step = 1,
value,
}: SliderProps) {
const handleValueChange = (values: number[]) => {
if (onChange) onChange(values[0]);
};
return (
<RadixSlider.Root
className={`${styles.sliderRoot} ${className}`}
defaultValue={[defaultValue]}
disabled={disabled}
max={max}
min={min}
step={step}
value={value !== undefined ? [value] : undefined}
onValueChange={handleValueChange}
>
<RadixSlider.Track className={styles.sliderTrack}>
<RadixSlider.Range className={styles.sliderRange} />
</RadixSlider.Track>
<RadixSlider.Thumb className={styles.sliderThumb} />
</RadixSlider.Root>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, forwardRef } from 'react';
import { useCallback, useEffect, forwardRef, useMemo } from 'react';
import { ImSpinner9 } from 'react-icons/im/index';
import { Range } from './range';
@@ -31,13 +31,19 @@ export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
const selectSound = useSoundStore(state => state.select);
const unselectSound = useSoundStore(state => state.unselect);
const setVolume = useSoundStore(state => state.setVolume);
const volume = useSoundStore(state => state.sounds[id].volume);
const isSelected = useSoundStore(state => state.sounds[id].isSelected);
const locked = useSoundStore(state => state.locked);
const volume = useSoundStore(state => state.sounds[id].volume);
const globalVolume = useSoundStore(state => state.globalVolume);
const adjustedVolume = useMemo(
() => volume * globalVolume,
[volume, globalVolume],
);
const isLoading = useLoadingStore(state => state.loaders[src]);
const sound = useSound(src, { loop: true, volume });
const sound = useSound(src, { loop: true, volume: adjustedVolume });
useEffect(() => {
if (locked) return;

View File

@@ -3,10 +3,7 @@ import { FaGithub } from 'react-icons/fa/index';
import { SpecialButton } from './special-button';
import { Container } from './container';
import { generateRandomBinaryString } from '@/helpers/binary';
const binary = generateRandomBinaryString(1000);
import { Binary } from './binary';
---
<div class="source">
@@ -28,7 +25,7 @@ const binary = generateRandomBinaryString(1000);
</SpecialButton>
</div>
<div class="binary">{binary}</div>
<div class="binary"><Binary client:load /></div>
</div>
</Container>
</div>

View File

@@ -0,0 +1,13 @@
import { FaHeadphonesAlt } from 'react-icons/fa/index';
import { Item } from '../item';
interface BinauralProps {
open: () => void;
}
export function Binaural({ open }: BinauralProps) {
return (
<Item icon={<FaHeadphonesAlt />} label="Binaural Beats" onClick={open} />
);
}

View File

@@ -10,3 +10,5 @@ export { Pomodoro as PomodoroItem } from './pomodoro';
export { Notepad as NotepadItem } from './notepad';
export { Todo as TodoItem } from './todo';
export { Countdown as CountdownItem } from './countdown';
export { Binaural as BinauralItem } from './binaural';
export { Isochronic as IsochronicItem } from './isochronic';

View File

@@ -0,0 +1,11 @@
import { TbWaveSine } from 'react-icons/tb/index';
import { Item } from '../item';
interface IsochronicProps {
open: () => void;
}
export function Isochronic({ open }: IsochronicProps) {
return <Item icon={<TbWaveSine />} label="Isochronic Tones" onClick={open} />;
}

View File

@@ -11,7 +11,7 @@ export function Presets({ open }: PresetsProps) {
<Item
icon={<RiPlayListFill />}
label="Your Presets"
shortcut="Shift + P"
shortcut="Shift + Alt + P"
onClick={open}
/>
);

View File

@@ -15,7 +15,7 @@ export function SleepTimer({ open }: SleepTimerProps) {
active={active}
icon={<IoMoonSharp />}
label="Sleep Timer"
shortcut="Shift + T"
shortcut="Shift + Alt + T"
onClick={open}
/>
);

View File

@@ -40,3 +40,21 @@
border: 1px solid var(--color-neutral-300);
border-radius: 8px;
}
.globalVolume {
width: 100%;
padding: 12px;
& label {
display: block;
margin-bottom: 8px;
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-foreground-subtle);
}
& input {
width: 100%;
min-width: 0;
}
}

View File

@@ -17,6 +17,8 @@ import {
NotepadItem,
TodoItem,
CountdownItem,
BinauralItem,
IsochronicItem,
} from './items';
import { Divider } from './divider';
import { ShareLinkModal } from '@/components/modals/share-link';
@@ -24,7 +26,11 @@ import { PresetsModal } from '@/components/modals/presets';
import { ShortcutsModal } from '@/components/modals/shortcuts';
import { SleepTimerModal } from '@/components/modals/sleep-timer';
import { BreathingExerciseModal } from '@/components/modals/breathing';
import { BinauralModal } from '@/components/modals/binaural';
import { IsochronicModal } from '@/components/modals/isochronic';
import { Pomodoro, Notepad, Todo, Countdown } from '@/components/toolbox';
import { Slider } from '@/components/slider';
import { fade, mix, slideY } from '@/lib/motion';
import { useSoundStore } from '@/stores/sound';
@@ -36,11 +42,15 @@ export function Menu() {
const [isOpen, setIsOpen] = useState(false);
const noSelected = useSoundStore(state => state.noSelected());
const globalVolume = useSoundStore(state => state.globalVolume);
const setGlobalVolume = useSoundStore(state => state.setGlobalVolume);
const initial = useMemo(
() => ({
binaural: false,
breathing: false,
countdown: false,
isochronic: false,
notepad: false,
pomodoro: false,
presets: false,
@@ -124,8 +134,29 @@ export function Menu() {
<TodoItem open={() => open('todo')} />
<BreathingExerciseItem open={() => open('breathing')} />
<Divider />
<BinauralItem open={() => open('binaural')} />
<IsochronicItem open={() => open('isochronic')} />
<Divider />
<ShortcutsItem open={() => open('shortcuts')} />
<Divider />
<div className={styles.globalVolume}>
<label htmlFor="global-volume">Global Volume</label>
<DropdownMenu.Item
asChild
onSelect={e => e.preventDefault()}
>
<Slider
max={100}
min={0}
value={globalVolume * 100}
onChange={value => setGlobalVolume(value / 100)}
/>
</DropdownMenu.Item>
</div>
<Divider />
<DonateItem />
@@ -163,6 +194,11 @@ export function Menu() {
show={modals.sleepTimer}
onClose={() => close('sleepTimer')}
/>
<BinauralModal show={modals.binaural} onClose={() => close('binaural')} />
<IsochronicModal
show={modals.isochronic}
onClose={() => close('isochronic')}
/>
</>
);
}

View File

@@ -30,7 +30,7 @@
}
}
& button {
& .delete {
display: flex;
align-items: center;
justify-content: center;

View File

@@ -1,5 +1,7 @@
import { FaRegTrashAlt } from 'react-icons/fa/index';
import { Checkbox } from '@/components/checkbox';
import { useTodoStore } from '@/stores/todo';
import { cn } from '@/helpers/styles';
@@ -21,19 +23,16 @@ export function Todo({ done, id, todo }: TodoProps) {
return (
<div className={styles.wrapper}>
<input
checked={done}
className={styles.checkbox}
type="checkbox"
onChange={handleCheck}
/>
<div className={styles.checkbox}>
<Checkbox checked={done} onChange={handleCheck} />
</div>
<input
className={cn(styles.textbox, done && styles.done)}
type="text"
value={todo}
onChange={e => editTodo(id, e.target.value)}
/>
<button onClick={handleDelete}>
<button className={styles.delete} onClick={handleDelete}>
<FaRegTrashAlt />
</button>
</div>

View File

@@ -6,22 +6,11 @@ import { places } from './sounds/places';
import { transport } from './sounds/transport';
import { things } from './sounds/things';
import { noise } from './sounds/noise';
import { binaural } from './sounds/binaural';
import type { Categories } from './types';
export const sounds: {
categories: Categories;
} = {
categories: [
nature,
rain,
animals,
urban,
places,
transport,
things,
noise,
binaural,
],
categories: [nature, rain, animals, urban, places, transport, things, noise],
};

View File

@@ -4,6 +4,11 @@ import {
GiWolfHead,
GiOwl,
GiWhaleTail,
GiTreeBeehive,
GiEgyptianBird,
GiChicken,
GiCow,
GiSheep,
} from 'react-icons/gi/index';
import {
FaDog,
@@ -86,6 +91,36 @@ export const animals: Category = {
label: 'Whale',
src: '/sounds/animals/whale.mp3',
},
{
icon: <GiTreeBeehive />,
id: 'beehive',
label: 'Beehive',
src: '/sounds/animals/beehive.mp3',
},
{
icon: <GiEgyptianBird />,
id: 'woodpecker',
label: 'Woodpecker',
src: '/sounds/animals/woodpecker.mp3',
},
{
icon: <GiChicken />,
id: 'chickens',
label: 'Chickens',
src: '/sounds/animals/chickens.mp3',
},
{
icon: <GiCow />,
id: 'cows',
label: 'Cows',
src: '/sounds/animals/cows.mp3',
},
{
icon: <GiSheep />,
id: 'sheep',
label: 'Sheep',
src: '/sounds/animals/sheep.mp3',
},
],
title: 'Animals',
};

View File

@@ -1,4 +1,4 @@
import { GiWaterfall } from 'react-icons/gi/index';
import { GiWaterfall, GiStonePile } from 'react-icons/gi/index';
import { BsFire, BsFillDropletFill } from 'react-icons/bs/index';
import { BiSolidTree, BiWater } from 'react-icons/bi/index';
import {
@@ -69,6 +69,12 @@ export const nature: Category = {
label: 'Walk on Leaves',
src: '/sounds/nature/walk-on-leaves.mp3',
},
{
icon: <GiStonePile />,
id: 'walk-on-gravel',
label: 'Walk on Gravel',
src: '/sounds/nature/walk-on-gravel.mp3',
},
{
icon: <BsFillDropletFill />,
id: 'droplets',

View File

@@ -13,6 +13,7 @@ import {
} from 'react-icons/md/index';
import { HiOfficeBuilding } from 'react-icons/hi/index';
import { AiFillExperiment } from 'react-icons/ai/index';
import { IoRestaurant } from 'react-icons/io5/index';
import type { Category } from '../types';
@@ -104,6 +105,12 @@ export const places: Category = {
label: 'Laundry Room',
src: '/sounds/places/laundry-room.mp3',
},
{
icon: <IoRestaurant />,
id: 'restaurant',
label: 'Restaurant',
src: '/sounds/places/restaurant.mp3',
},
],
title: 'Places',
};

View File

@@ -4,7 +4,7 @@ import {
BsUmbrellaFill,
} from 'react-icons/bs/index';
import { GiWindow } from 'react-icons/gi/index';
import { FaLeaf } from 'react-icons/fa/index';
import { FaLeaf, FaCarSide } from 'react-icons/fa/index';
import { PiTentFill } from 'react-icons/pi/index';
import { MdOutlineThunderstorm } from 'react-icons/md/index';
@@ -38,6 +38,12 @@ export const rain: Category = {
label: 'Rain on Window',
src: '/sounds/rain/rain-on-window.mp3',
},
{
icon: <FaCarSide />,
id: 'rain-on-car-roof',
label: 'Rain on Car Roof',
src: '/sounds/rain/rain-on-car-roof.mp3',
},
{
icon: <BsUmbrellaFill />,
id: 'rain-on-umbrella',

View File

@@ -6,10 +6,11 @@ import {
import { BsFillKeyboardFill } from 'react-icons/bs/index';
import { FaKeyboard, FaClock, FaFan } from 'react-icons/fa/index';
import { MdSmartToy, MdWaterDrop, MdRadio } from 'react-icons/md/index';
import { TbBowlFilled } from 'react-icons/tb/index';
import { TbBowlFilled, TbWiper } from 'react-icons/tb/index';
import { RiFilePaper2Fill, RiBubbleChartFill } from 'react-icons/ri/index';
import { BiSolidDryer } from 'react-icons/bi/index';
import { IoIosRadio } from 'react-icons/io/index';
import { PiVinylRecord } from 'react-icons/pi/index';
import type { Category } from '../types';
@@ -101,6 +102,18 @@ export const things: Category = {
label: 'Washing Machine',
src: '/sounds/things/washing-machine.mp3',
},
{
icon: <PiVinylRecord />,
id: 'vinyl-effect',
label: 'Vinyl Effect',
src: '/sounds/things/vinyl-effect.mp3',
},
{
icon: <TbWiper />,
id: 'windshield-wipers',
label: 'Windshield Wipers',
src: '/sounds/things/windshield-wipers.mp3',
},
],
title: 'Things',
};

View File

@@ -0,0 +1,16 @@
export class BrowserDetect {
private static _isSafari: boolean | undefined;
public static isSafari(): boolean {
if (typeof BrowserDetect._isSafari !== 'undefined') {
return BrowserDetect._isSafari;
}
// Source: https://github.com/goldfire/howler.js/blob/v2.2.4/src/howler.core.js#L270
BrowserDetect._isSafari =
navigator.userAgent.indexOf('Safari') !== -1 &&
navigator.userAgent.indexOf('Chrome') === -1;
return BrowserDetect._isSafari;
}
}

82
src/helpers/sound.ts Normal file
View File

@@ -0,0 +1,82 @@
function blobToDataURL(blob: Blob) {
return new Promise<string>(resolve => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result !== 'string') return;
resolve(reader.result);
};
reader.readAsDataURL(blob);
});
}
function writeString(view: DataView, offset: number, string: string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
function encodeWAV(audioBuffer: AudioBuffer) {
const numChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
const length = audioBuffer.length * numChannels * 2 + 44; // Header + PCM data
const wavBuffer = new ArrayBuffer(length);
const view = new DataView(wavBuffer);
// WAV file header
writeString(view, 0, 'RIFF');
// File size - 8
view.setUint32(4, 36 + audioBuffer.length * numChannels * 2, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
// Subchunk1Size
view.setUint32(16, 16, true);
// Audio format (PCM)
view.setUint16(20, 1, true);
// NumChannels
view.setUint16(22, numChannels, true);
// SampleRate
view.setUint32(24, sampleRate, true);
// ByteRate
view.setUint32(28, sampleRate * numChannels * 2, true);
// BlockAlign
view.setUint16(32, numChannels * 2, true);
// BitsPerSample
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
// Subchunk2Size
view.setUint32(40, audioBuffer.length * numChannels * 2, true);
// Write interleaved PCM samples
let offset = 44;
for (let i = 0; i < audioBuffer.length; i++) {
for (let channel = 0; channel < numChannels; channel++) {
const sample = audioBuffer.getChannelData(channel)[i];
const clampedSample = Math.max(-1, Math.min(1, sample));
view.setInt16(offset, clampedSample * 0x7fff, true);
offset += 2;
}
}
return wavBuffer;
}
export async function getSilenceDataURL(seconds: number = 60) {
const audioContext = new AudioContext();
const sampleRate = 44100;
const length = sampleRate * seconds;
const buffer = audioContext.createBuffer(1, length, sampleRate);
const channelData = buffer.getChannelData(0);
/**
* - Firefox ignores audio for Media Session without any actual sound in the beginning.
* - Add a small value to the end to prevent clipping.
*/
channelData[0] = 0.001;
channelData[channelData.length - 1] = 0.001;
return await blobToDataURL(
new Blob([encodeWAV(buffer)], { type: 'audio/wav' }),
);
}

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
import { useSSR } from './use-ssr';
const themeMatch = '(prefers-color-scheme: dark)';
export function useDarkTheme() {
const { isBrowser } = useSSR();
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(false);
useEffect(() => {
if (!isBrowser) return;
const themeMediaQuery = window.matchMedia(themeMatch);
function handleThemeChange(event: MediaQueryListEvent) {
setIsDarkTheme(event.matches);
}
themeMediaQuery.addEventListener('change', handleThemeChange);
setIsDarkTheme(themeMediaQuery.matches);
return () =>
themeMediaQuery.removeEventListener('change', handleThemeChange);
}, [isBrowser]);
return isDarkTheme;
}

View File

@@ -19,7 +19,10 @@ export const useSoundStore = create<SoundState & SoundActions>()(
persisted,
),
name: 'moodist-sounds',
partialize: state => ({ sounds: state.sounds }),
partialize: state => ({
globalVolume: state.globalVolume,
sounds: state.sounds,
}),
skipHydration: true,
storage: createJSONStorage(() => localStorage),
version: 0,

View File

@@ -11,6 +11,7 @@ export interface SoundActions {
play: () => void;
restoreHistory: () => void;
select: (id: string) => void;
setGlobalVolume: (volume: number) => void;
setVolume: (id: string, volume: number) => void;
shuffle: () => void;
toggleFavorite: (id: string) => void;
@@ -72,6 +73,12 @@ export const createActions: StateCreator<
});
},
setGlobalVolume(volume) {
set({
globalVolume: volume,
});
},
setVolume(id, volume) {
set({
sounds: {

View File

@@ -6,6 +6,7 @@ import { sounds } from '@/data/sounds';
export interface SoundState {
getFavorites: () => Array<string>;
globalVolume: number;
history: {
[id: string]: {
isFavorite: boolean;
@@ -39,6 +40,7 @@ export const createState: StateCreator<
return favorites;
},
globalVolume: 1,
history: null,
isPlaying: false,
locked: false,