summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src-tauri/Cargo.lock418
-rw-r--r--src-tauri/Cargo.toml1
-rw-r--r--src-tauri/audio/cafe.oggbin0 -> 254428 bytes
-rw-r--r--src-tauri/audio/rain.oggbin0 -> 256573 bytes
-rw-r--r--src-tauri/audio/white_noise.oggbin0 -> 263937 bytes
-rw-r--r--src-tauri/src/audio.rs188
-rw-r--r--src-tauri/src/lib.rs76
-rw-r--r--src-tauri/src/timer.rs10
-rw-r--r--src-tauri/tauri.conf.json3
-rw-r--r--src/App.tsx11
-rw-r--r--src/components/AmbientControl.tsx98
-rw-r--r--src/store/audioStore.ts84
12 files changed, 870 insertions, 19 deletions
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 9d35925..cb2fbcd 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -33,6 +33,28 @@ dependencies = [
]
[[package]]
+name = "alsa"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
+dependencies = [
+ "alsa-sys",
+ "bitflags 2.13.0",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "alsa-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -48,6 +70,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -226,6 +254,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
+name = "bindgen"
+version = "0.72.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
+dependencies = [
+ "bitflags 2.13.0",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex 1.3.0",
+ "syn 2.0.117",
+]
+
+[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -417,7 +463,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
- "shlex",
+ "jobserver",
+ "libc",
+ "shlex 2.0.1",
]
[[package]]
@@ -427,6 +475,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
name = "cfb"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -468,6 +525,23 @@ dependencies = [
]
[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading 0.8.9",
+]
+
+[[package]]
+name = "claxon"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
+
+[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -537,6 +611,49 @@ dependencies = [
]
[[package]]
+name = "coreaudio-rs"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation-sys",
+ "coreaudio-sys",
+]
+
+[[package]]
+name = "coreaudio-sys"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
+dependencies = [
+ "bindgen",
+]
+
+[[package]]
+name = "cpal"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
+dependencies = [
+ "alsa",
+ "core-foundation-sys",
+ "coreaudio-rs",
+ "dasp_sample",
+ "jni",
+ "js-sys",
+ "libc",
+ "mach2",
+ "ndk 0.8.0",
+ "ndk-context",
+ "oboe",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows 0.54.0",
+]
+
+[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -653,6 +770,12 @@ dependencies = [
]
[[package]]
+name = "dasp_sample"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
+
+[[package]]
name = "dbus"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -838,6 +961,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
name = "embed-resource"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -858,6 +987,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1450,6 +1588,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
+name = "hound"
+version = "3.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
+
+[[package]]
name = "html5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1748,6 +1892,15 @@ dependencies = [
]
[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1821,6 +1974,16 @@ dependencies = [
]
[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
name = "js-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1865,12 +2028,29 @@ dependencies = [
]
[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
+name = "lewton"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
+dependencies = [
+ "byteorder",
+ "ogg",
+ "tinyvec",
+]
+
+[[package]]
name = "libappindicator"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1890,7 +2070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
dependencies = [
"gtk-sys",
- "libloading",
+ "libloading 0.7.4",
"once_cell",
]
@@ -1920,6 +2100,16 @@ dependencies = [
]
[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link 0.2.1",
+]
+
+[[package]]
name = "libredox"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1968,6 +2158,15 @@ dependencies = [
]
[[package]]
+name = "mach2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "markup5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2000,6 +2199,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2043,6 +2248,20 @@ dependencies = [
[[package]]
name = "ndk"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
+dependencies = [
+ "bitflags 2.13.0",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys 0.5.0+25.2.9519653",
+ "num_enum",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
@@ -2050,13 +2269,28 @@ dependencies = [
"bitflags 2.13.0",
"jni-sys 0.3.1",
"log",
- "ndk-sys",
+ "ndk-sys 0.6.0+11769913",
"num_enum",
"raw-window-handle",
"thiserror 1.0.69",
]
[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.5.0+25.2.9519653"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2072,6 +2306,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
name = "notify-rust"
version = "4.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2092,6 +2336,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2319,6 +2574,38 @@ dependencies = [
]
[[package]]
+name = "oboe"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
+dependencies = [
+ "jni",
+ "ndk 0.8.0",
+ "ndk-context",
+ "num-derive",
+ "num-traits",
+ "oboe-sys",
+]
+
+[[package]]
+name = "oboe-sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ogg"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2827,6 +3114,20 @@ dependencies = [
]
[[package]]
+name = "rodio"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb"
+dependencies = [
+ "claxon",
+ "cpal",
+ "hound",
+ "lewton",
+ "symphonia",
+ "thiserror 1.0.69",
+]
+
+[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3126,6 +3427,12 @@ dependencies = [
[[package]]
name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
@@ -3182,7 +3489,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
dependencies = [
"bytemuck",
"js-sys",
- "ndk",
+ "ndk 0.9.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -3270,6 +3577,55 @@ dependencies = [
]
[[package]]
+name = "symphonia"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
+dependencies = [
+ "lazy_static",
+ "symphonia-bundle-mp3",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-bundle-mp3"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-core"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
+dependencies = [
+ "arrayvec",
+ "bitflags 1.3.2",
+ "bytemuck",
+ "lazy_static",
+ "log",
+]
+
+[[package]]
+name = "symphonia-metadata"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
+dependencies = [
+ "encoding_rs",
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3344,8 +3700,8 @@ dependencies = [
"jni",
"libc",
"log",
- "ndk",
- "ndk-sys",
+ "ndk 0.9.0",
+ "ndk-sys 0.6.0+11769913",
"objc2",
"objc2-app-kit",
"objc2-foundation",
@@ -3357,7 +3713,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
- "windows",
+ "windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -3428,7 +3784,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
- "windows",
+ "windows 0.61.3",
]
[[package]]
@@ -3436,6 +3792,7 @@ name = "tauri-app"
version = "0.1.0"
dependencies = [
"chrono",
+ "rodio",
"serde",
"serde_json",
"tauri",
@@ -3560,7 +3917,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.18",
"url",
- "windows",
+ "windows 0.61.3",
"zbus",
]
@@ -3586,7 +3943,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
- "windows",
+ "windows 0.61.3",
]
[[package]]
@@ -3611,7 +3968,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
- "windows",
+ "windows 0.61.3",
"wry",
]
@@ -3672,7 +4029,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
- "windows",
+ "windows 0.61.3",
"windows-version",
]
@@ -4428,7 +4785,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
- "windows",
+ "windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
@@ -4452,7 +4809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
- "windows",
+ "windows 0.61.3",
"windows-core 0.61.2",
]
@@ -4504,6 +4861,16 @@ dependencies = [
[[package]]
name = "windows"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
+dependencies = [
+ "windows-core 0.54.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
@@ -4526,6 +4893,16 @@ dependencies = [
[[package]]
name = "windows-core"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
+dependencies = [
+ "windows-result 0.1.2",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
@@ -4607,6 +4984,15 @@ dependencies = [
[[package]]
name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
@@ -4961,7 +5347,7 @@ dependencies = [
"javascriptcore-rs",
"jni",
"libc",
- "ndk",
+ "ndk 0.9.0",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -4979,7 +5365,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
- "windows",
+ "windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 214a48f..adbd36e 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -25,4 +25,5 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
+rodio = "0.19"
diff --git a/src-tauri/audio/cafe.ogg b/src-tauri/audio/cafe.ogg
new file mode 100644
index 0000000..cbc1007
--- /dev/null
+++ b/src-tauri/audio/cafe.ogg
Binary files differ
diff --git a/src-tauri/audio/rain.ogg b/src-tauri/audio/rain.ogg
new file mode 100644
index 0000000..fd5d478
--- /dev/null
+++ b/src-tauri/audio/rain.ogg
Binary files differ
diff --git a/src-tauri/audio/white_noise.ogg b/src-tauri/audio/white_noise.ogg
new file mode 100644
index 0000000..fcbd7bc
--- /dev/null
+++ b/src-tauri/audio/white_noise.ogg
Binary files differ
diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs
new file mode 100644
index 0000000..a439a0c
--- /dev/null
+++ b/src-tauri/src/audio.rs
@@ -0,0 +1,188 @@
+use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source};
+use std::fs::File;
+use std::io::BufReader;
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+use std::thread;
+
+use crate::state::TimerPhase;
+
+// OutputStream is !Send, so we keep it alive in a dedicated thread.
+// Only OutputStreamHandle and Sink (both Send) cross thread boundaries.
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum AmbientSound {
+ Rain,
+ Cafe,
+ WhiteNoise,
+}
+
+impl AmbientSound {
+ pub fn filename(&self) -> &'static str {
+ match self {
+ AmbientSound::Rain => "rain.ogg",
+ AmbientSound::Cafe => "cafe.ogg",
+ AmbientSound::WhiteNoise => "white_noise.ogg",
+ }
+ }
+
+ pub fn name(&self) -> &'static str {
+ match self {
+ AmbientSound::Rain => "rain",
+ AmbientSound::Cafe => "cafe",
+ AmbientSound::WhiteNoise => "white_noise",
+ }
+ }
+
+ pub fn from_str(s: &str) -> Option<Self> {
+ match s {
+ "rain" => Some(AmbientSound::Rain),
+ "cafe" => Some(AmbientSound::Cafe),
+ "white_noise" => Some(AmbientSound::WhiteNoise),
+ _ => None,
+ }
+ }
+}
+
+pub struct AudioEngine {
+ stream_handle: OutputStreamHandle,
+ sink: Option<Sink>,
+ current_sound: Option<AmbientSound>,
+ volume: f32,
+ ducked: bool,
+ duck_volume: f32,
+ audio_dir: PathBuf,
+}
+
+impl AudioEngine {
+ fn new_with_handle(stream_handle: OutputStreamHandle, audio_dir: PathBuf) -> Self {
+ Self {
+ stream_handle,
+ sink: None,
+ current_sound: None,
+ volume: 0.5,
+ ducked: false,
+ duck_volume: 0.2,
+ audio_dir,
+ }
+ }
+
+ pub fn play(&mut self, sound: AmbientSound) -> Result<(), String> {
+ self.stop();
+ let filename = sound.filename();
+ let path = self.audio_dir.join(filename);
+ let file = File::open(&path).map_err(|e| format!("Cannot open {filename}: {e}"))?;
+ let source = Decoder::new(BufReader::new(file))
+ .map_err(|e| format!("Decode error: {e}"))?;
+ let sink = Sink::try_new(&self.stream_handle)
+ .map_err(|e| format!("Sink error: {e}"))?;
+ sink.append(source.repeat_infinite());
+ sink.set_volume(if self.ducked { self.duck_volume } else { self.volume });
+ sink.play();
+ self.current_sound = Some(sound);
+ self.sink = Some(sink);
+ Ok(())
+ }
+
+ pub fn stop(&mut self) {
+ if let Some(sink) = self.sink.take() {
+ sink.stop();
+ }
+ self.current_sound = None;
+ }
+
+ pub fn set_volume(&mut self, volume: f32) {
+ self.volume = volume.clamp(0.0, 1.0);
+ if let Some(ref sink) = self.sink {
+ if !self.ducked {
+ sink.set_volume(self.volume);
+ }
+ }
+ }
+
+ pub fn duck(&mut self) {
+ self.ducked = true;
+ if let Some(ref sink) = self.sink {
+ sink.set_volume(self.duck_volume);
+ }
+ }
+
+ pub fn unduck(&mut self) {
+ self.ducked = false;
+ if let Some(ref sink) = self.sink {
+ sink.set_volume(self.volume);
+ }
+ }
+
+ pub fn is_playing(&self) -> bool {
+ self.sink.is_some()
+ }
+
+ pub fn current_sound(&self) -> Option<AmbientSound> {
+ self.current_sound
+ }
+
+ pub fn volume(&self) -> f32 {
+ self.volume
+ }
+}
+
+pub fn should_duck_for_phase(phase: TimerPhase) -> bool {
+ !matches!(phase, TimerPhase::Work)
+}
+
+/// Managed state: Arc<Mutex<Option<AudioEngine>>>.
+/// None means audio init failed (graceful degradation).
+pub struct AudioState(pub Arc<Mutex<Option<AudioEngine>>>);
+
+/// Initialise the audio subsystem.
+///
+/// Spawns a dedicated thread to keep `OutputStream` alive (it is `!Send`).
+/// Returns `AudioState` whose inner `Option` is `None` if no audio device exists.
+pub fn init_audio(audio_dir: PathBuf) -> AudioState {
+ let state: Arc<Mutex<Option<AudioEngine>>> = Arc::new(Mutex::new(None));
+ let state_clone = Arc::clone(&state);
+
+ thread::spawn(move || {
+ match OutputStream::try_default() {
+ Ok((_stream, handle)) => {
+ let engine = AudioEngine::new_with_handle(handle, audio_dir);
+ {
+ let mut guard = state_clone.lock().unwrap();
+ *guard = Some(engine);
+ }
+ // Park forever — _stream must stay alive to keep audio device open.
+ loop {
+ thread::park();
+ }
+ }
+ Err(e) => {
+ eprintln!("[audio] No audio output device: {e}");
+ // state remains None
+ }
+ }
+ });
+
+ // Give the audio thread a moment to initialise before returning.
+ // Commands will work fine regardless due to Option<AudioEngine> guard.
+ thread::sleep(std::time::Duration::from_millis(100));
+
+ AudioState(state)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::should_duck_for_phase;
+ use crate::state::TimerPhase;
+
+ #[test]
+ fn work_phase_does_not_duck() {
+ assert!(!should_duck_for_phase(TimerPhase::Work));
+ }
+
+ #[test]
+ fn break_phases_duck() {
+ assert!(should_duck_for_phase(TimerPhase::ShortBreak));
+ assert!(should_duck_for_phase(TimerPhase::LongBreak));
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index c9ebbd9..d1864eb 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,12 +1,15 @@
+mod audio;
mod state;
mod storage;
mod timer;
use std::sync::{Arc, Mutex};
use chrono::Utc;
+use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use uuid::Uuid;
+use audio::AudioState;
use state::{AppDataWrapper, TimerPhase, TimerState, TimerStateWrapper};
use storage::{Settings, Task};
@@ -252,6 +255,66 @@ fn delete_task(
Ok(())
}
+// ── Audio commands ──────────────────────────────────────────────────────────
+
+#[derive(Serialize)]
+struct AudioStatus {
+ available: bool,
+ playing: bool,
+ sound: Option<String>,
+ volume: f32,
+}
+
+#[tauri::command]
+fn play_ambient(sound: String, audio: State<'_, AudioState>) -> Result<(), String> {
+ let mut guard = audio.0.lock().unwrap();
+ match guard.as_mut() {
+ None => Err("Audio not available".to_string()),
+ Some(engine) => {
+ if sound == "none" {
+ engine.stop();
+ Ok(())
+ } else {
+ match audio::AmbientSound::from_str(&sound) {
+ Some(s) => engine.play(s),
+ None => Err(format!("Unknown sound: {sound}")),
+ }
+ }
+ }
+ }
+}
+
+#[tauri::command]
+fn stop_ambient(audio: State<'_, AudioState>) {
+ if let Some(ref mut engine) = *audio.0.lock().unwrap() {
+ engine.stop();
+ }
+}
+
+#[tauri::command]
+fn set_ambient_volume(volume: f32, audio: State<'_, AudioState>) -> Result<(), String> {
+ match audio.0.lock().unwrap().as_mut() {
+ None => Err("Audio not available".to_string()),
+ Some(engine) => {
+ engine.set_volume(volume);
+ Ok(())
+ }
+ }
+}
+
+#[tauri::command]
+fn get_audio_status(audio: State<'_, AudioState>) -> AudioStatus {
+ match audio.0.lock().unwrap().as_ref() {
+ None => AudioStatus { available: false, playing: false, sound: None, volume: 0.5 },
+ Some(engine) => AudioStatus {
+ available: true,
+ playing: engine.is_playing(),
+ sound: engine.current_sound().map(|s| s.name().to_string()),
+ volume: engine.volume(),
+ },
+ }
+}
+
// ── App entry point ─────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -281,11 +344,20 @@ pub fn run() {
data: Arc::clone(&data_arc),
});
+ // Initialise audio engine (graceful if no device)
+ let audio_dir = app.handle().path().resource_dir()
+ .unwrap_or_else(|_| std::path::PathBuf::from("."))
+ .join("audio");
+ let audio_state = audio::init_audio(audio_dir);
+ let audio_arc = Arc::clone(&audio_state.0);
+ app.manage(audio_state);
+
// Spawn background timer thread
timer::spawn_timer_thread(
app.handle().clone(),
Arc::clone(&timer_arc),
Arc::clone(&data_arc),
+ audio_arc,
data_dir,
);
@@ -304,6 +376,10 @@ pub fn run() {
add_task,
update_task,
delete_task,
+ play_ambient,
+ stop_ambient,
+ set_ambient_volume,
+ get_audio_status,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src-tauri/src/timer.rs b/src-tauri/src/timer.rs
index 44e5999..322795e 100644
--- a/src-tauri/src/timer.rs
+++ b/src-tauri/src/timer.rs
@@ -4,6 +4,7 @@ use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
+use crate::audio::{self, AudioEngine};
use crate::state::{TimerPhase, TimerState};
use crate::storage::{self, AppData};
@@ -41,6 +42,7 @@ pub fn spawn_timer_thread(
app_handle: AppHandle,
timer_arc: Arc<Mutex<TimerState>>,
data_arc: Arc<Mutex<AppData>>,
+ audio_arc: Arc<Mutex<Option<AudioEngine>>>,
data_dir: std::path::PathBuf,
) {
thread::spawn(move || {
@@ -128,6 +130,14 @@ pub fn spawn_timer_thread(
}
// Emit phase-changed after state is updated
+ if let Some(engine) = audio_arc.lock().unwrap().as_mut() {
+ if audio::should_duck_for_phase(ts.phase) {
+ engine.duck();
+ } else {
+ engine.unduck();
+ }
+ }
+
let phase_changed = PhaseChangedPayload {
phase: ts.phase,
session_count: ts.session_count,
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index b39e99f..e634dca 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -34,6 +34,7 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
- ]
+ ],
+ "resources": ["audio/*"]
}
}
diff --git a/src/App.tsx b/src/App.tsx
index 966e8e1..cf1e6a6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,8 +3,10 @@ import { TimerView } from './components/TimerView';
import { TaskList } from './components/TaskList';
import { SettingsPanel } from './components/SettingsPanel';
import { NotificationOverlay } from './components/NotificationOverlay';
+import { AmbientControl } from './components/AmbientControl';
import { useTimerEvents } from './hooks/useTimerEvents';
import { useTaskStore } from './store/taskStore';
+import { useAudioStore } from './store/audioStore';
import { useSettingsStore } from './store/settingsStore';
function GearIcon() {
@@ -32,12 +34,14 @@ function App() {
const fetchTasks = useTaskStore((s) => s.fetchTasks);
const fetchSettings = useSettingsStore((s) => s.fetchSettings);
+ const fetchAudioStatus = useAudioStore((s) => s.fetchStatus);
// Bootstrap data on mount
useEffect(() => {
fetchTasks();
fetchSettings();
- }, [fetchTasks, fetchSettings]);
+ fetchAudioStatus();
+ }, [fetchTasks, fetchSettings, fetchAudioStatus]);
const handleCompleted = useCallback((taskId: string | null) => {
setNotifTaskId(taskId);
@@ -67,11 +71,14 @@ function App() {
style={{
display: 'flex',
alignItems: 'center',
- justifyContent: 'flex-end',
+ justifyContent: 'space-between',
+ gap: '16px',
padding: '12px 16px',
borderBottom: '1px solid var(--line-1)',
}}
>
+ <AmbientControl />
+
<button
onClick={() => setSettingsOpen(true)}
title="Settings"
diff --git a/src/components/AmbientControl.tsx b/src/components/AmbientControl.tsx
new file mode 100644
index 0000000..5b5017a
--- /dev/null
+++ b/src/components/AmbientControl.tsx
@@ -0,0 +1,98 @@
+import { ChangeEvent } from 'react';
+import { useAudioStore, type AmbientSound } from '../store/audioStore';
+
+const SOUND_OPTIONS: Array<{ value: AmbientSound; label: string }> = [
+ { value: 'none', label: 'Silent' },
+ { value: 'rain', label: 'Rain' },
+ { value: 'cafe', label: 'Cafe' },
+ { value: 'white_noise', label: 'White Noise' },
+];
+
+export function AmbientControl() {
+ const { available, sound, volume, setSound, setVolume } = useAudioStore();
+
+ const handleSoundChange = async (event: ChangeEvent<HTMLSelectElement>) => {
+ await setSound(event.target.value as AmbientSound);
+ };
+
+ const handleVolumeChange = async (event: ChangeEvent<HTMLInputElement>) => {
+ await setVolume(Number(event.target.value));
+ };
+
+ return (
+ <div
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '12px',
+ padding: '8px 10px',
+ border: '1px solid var(--line-2)',
+ borderRadius: 'var(--r-3)',
+ background: 'rgba(17, 21, 28, 0.85)',
+ boxShadow: 'var(--shadow-1)',
+ }}
+ >
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '90px' }}>
+ <span className="eyebrow" style={{ letterSpacing: '0.14em' }}>
+ Ambient
+ </span>
+ <span
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ color: available ? 'var(--fg-3)' : 'var(--negative)',
+ }}
+ >
+ {available ? 'Looping background audio' : 'Audio unavailable'}
+ </span>
+ </div>
+
+ <select
+ value={sound}
+ onChange={handleSoundChange}
+ disabled={!available}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '13px',
+ color: available ? 'var(--fg-1)' : 'var(--fg-4)',
+ background: 'var(--ink-3)',
+ border: '1px solid var(--line-2)',
+ borderRadius: 'var(--r-2)',
+ padding: '6px 10px',
+ outline: 'none',
+ minWidth: '132px',
+ }}
+ >
+ {SOUND_OPTIONS.map((option) => (
+ <option key={option.value} value={option.value}>
+ {option.label}
+ </option>
+ ))}
+ </select>
+
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+ <input
+ type="range"
+ min={0}
+ max={1}
+ step={0.05}
+ value={volume}
+ onChange={handleVolumeChange}
+ disabled={!available}
+ style={{ width: '112px', accentColor: 'var(--brass)' }}
+ />
+ <span
+ style={{
+ fontFamily: 'var(--font-mono)',
+ fontSize: '12px',
+ color: available ? 'var(--fg-2)' : 'var(--fg-4)',
+ width: '36px',
+ textAlign: 'right',
+ }}
+ >
+ {Math.round(volume * 100)}%
+ </span>
+ </div>
+ </div>
+ );
+}
diff --git a/src/store/audioStore.ts b/src/store/audioStore.ts
new file mode 100644
index 0000000..39cbf8b
--- /dev/null
+++ b/src/store/audioStore.ts
@@ -0,0 +1,84 @@
+import { invoke } from '@tauri-apps/api/core';
+import { create } from 'zustand';
+
+export type AmbientSound = 'none' | 'rain' | 'cafe' | 'white_noise';
+
+interface AudioStatus {
+ available: boolean;
+ playing: boolean;
+ sound: AmbientSound | null;
+ volume: number;
+}
+
+interface AudioStore {
+ available: boolean;
+ playing: boolean;
+ sound: AmbientSound;
+ volume: number;
+ fetchStatus: () => Promise<void>;
+ setSound: (sound: AmbientSound) => Promise<void>;
+ setVolume: (volume: number) => Promise<void>;
+}
+
+function normalizeSound(sound: AudioStatus['sound'], playing: boolean): AmbientSound {
+ if (!playing || sound === null) {
+ return 'none';
+ }
+
+ return sound;
+}
+
+export const useAudioStore = create<AudioStore>((set, get) => ({
+ available: true,
+ playing: false,
+ sound: 'none',
+ volume: 0.5,
+
+ fetchStatus: async () => {
+ try {
+ const status = await invoke<AudioStatus>('get_audio_status');
+ set({
+ available: status.available,
+ playing: status.playing,
+ sound: normalizeSound(status.sound, status.playing),
+ volume: status.volume,
+ });
+ } catch (error) {
+ console.error('get_audio_status error:', error);
+ set({ available: false, playing: false, sound: 'none' });
+ }
+ },
+
+ setSound: async (sound) => {
+ try {
+ if (sound === 'none') {
+ await invoke('stop_ambient');
+ set({ playing: false, sound: 'none' });
+ return;
+ }
+
+ await invoke('play_ambient', { sound });
+ set({ available: true, playing: true, sound });
+ } catch (error) {
+ console.error('play_ambient error:', error);
+ set({ available: false, playing: false, sound: 'none' });
+ }
+ },
+
+ setVolume: async (volume) => {
+ const nextVolume = Math.min(1, Math.max(0, volume));
+ set({ volume: nextVolume });
+
+ try {
+ await invoke('set_ambient_volume', { volume: nextVolume });
+ set({ available: true });
+ } catch (error) {
+ console.error('set_ambient_volume error:', error);
+ set({
+ available: false,
+ playing: false,
+ sound: get().sound,
+ });
+ }
+ },
+}));