Generated
+281
-17
@@ -32,6 +32,28 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
|
||||||
|
dependencies = [
|
||||||
|
"alsa-sys",
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa-sys"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -534,6 +556,50 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-rs"
|
||||||
|
version = "0.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"libc",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpal"
|
||||||
|
version = "0.18.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
|
||||||
|
dependencies = [
|
||||||
|
"alsa",
|
||||||
|
"block2",
|
||||||
|
"coreaudio-rs",
|
||||||
|
"dasp_sample",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"mach2",
|
||||||
|
"ndk",
|
||||||
|
"ndk-context",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"objc2",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-avf-audio",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"web-sys",
|
||||||
|
"windows 0.62.2",
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -650,6 +716,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbus"
|
name = "dbus"
|
||||||
version = "0.9.12"
|
version = "0.9.12"
|
||||||
@@ -889,6 +961,9 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
|||||||
name = "enclave"
|
name = "enclave"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"cpal",
|
||||||
|
"resampler",
|
||||||
|
"ringbuf",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -1861,6 +1936,36 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2018,6 +2123,12 @@ version = "0.4.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2107,6 +2218,12 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"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]]
|
[[package]]
|
||||||
name = "ndk-sys"
|
name = "ndk-sys"
|
||||||
version = "0.6.0+11769913"
|
version = "0.6.0+11769913"
|
||||||
@@ -2128,6 +2245,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
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.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2182,6 +2310,32 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-audio-toolbox"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-avf-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-cloud-kit"
|
name = "objc2-cloud-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2193,6 +2347,29 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||||
|
dependencies = [
|
||||||
|
"dispatch2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-data"
|
name = "objc2-core-data"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2210,7 +2387,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
|
"block2",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2282,6 +2461,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2813,6 +2993,23 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "resampler"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28fdbea87ff02ebbfd904079d1e52138c0c7fbaa3aaddca4a1b9c7d3f85749f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ringbuf"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a158e09ede21a14b172ca6cdd6208386c6ae2cb6acef58d774368ef8c450dfa7"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -3134,6 +3331,22 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -3340,7 +3553,7 @@ dependencies = [
|
|||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gtk",
|
"gtk",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
@@ -3356,7 +3569,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -3396,7 +3609,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -3427,7 +3640,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3526,7 +3739,7 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3540,7 +3753,7 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
@@ -3552,7 +3765,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3563,7 +3776,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -3577,7 +3790,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4325,7 +4538,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
@@ -4349,7 +4562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4405,11 +4618,23 @@ version = "0.61.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections 0.2.0",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-future",
|
"windows-future 0.2.1",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-numerics",
|
"windows-numerics 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections 0.3.2",
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
"windows-future 0.3.2",
|
||||||
|
"windows-numerics 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4421,6 +4646,15 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -4455,7 +4689,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-threading",
|
"windows-threading 0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-threading 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4502,6 +4747,16 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -4605,6 +4860,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-version"
|
name = "windows-version"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -4768,7 +5032,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -4788,7 +5052,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ tauri = { version = "2", features = [] }
|
|||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
cpal = "0.18.2"
|
||||||
|
resampler = "0.5.1"
|
||||||
|
ringbuf = "0.5.1"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
net::UdpSocket,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use ringbuf::{
|
||||||
|
storage::Heap,
|
||||||
|
traits::{Consumer, Observer, Producer, Split},
|
||||||
|
CachingCons, CachingProd, HeapRb, SharedRb,
|
||||||
|
};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::commands::config::ConfigState;
|
||||||
|
|
||||||
|
const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz
|
||||||
|
const HEADER_SIZE: usize = 4;
|
||||||
|
const MAX_PACKET_SIZE: usize = 4096;
|
||||||
|
|
||||||
|
const INITIAL_PACKET_CUSHION: usize = 3; // ~60ms cushion
|
||||||
|
|
||||||
|
// --- Voice Activity Detection (VAD) Settings ---
|
||||||
|
const VAD_THRESHOLD: f32 = 0.01;
|
||||||
|
const VAD_HANGOVER_FRAMES: usize = 10;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STATE & TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type AudioProducer = Arc<Mutex<CachingProd<Arc<SharedRb<Heap<f32>>>>>>;
|
||||||
|
type AudioConsumer = Arc<Mutex<CachingCons<Arc<SharedRb<Heap<f32>>>>>>;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct VoiceState {
|
||||||
|
pub inner: Arc<VoiceStateInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct VoiceStateInner {
|
||||||
|
pub session: Mutex<Option<VoiceSession>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VoiceSession {
|
||||||
|
pub input_stream: cpal::Stream,
|
||||||
|
pub output_stream: Arc<Mutex<cpal::Stream>>,
|
||||||
|
pub socket: Arc<UdpSocket>,
|
||||||
|
pub pin: u64,
|
||||||
|
pub shutdown: Arc<AtomicBool>,
|
||||||
|
|
||||||
|
pub current_input_device: Option<String>,
|
||||||
|
pub current_output_device: Option<String>,
|
||||||
|
|
||||||
|
pub producer_in: AudioProducer,
|
||||||
|
pub consumer_out: AudioConsumer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceSession {
|
||||||
|
pub fn update_input_device(
|
||||||
|
&mut self,
|
||||||
|
device_name: Option<String>,
|
||||||
|
state_inner: Arc<VoiceStateInner>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let device = match &device_name {
|
||||||
|
Some(name) => host
|
||||||
|
.input_devices()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.find(|d| {
|
||||||
|
d.description()
|
||||||
|
.ok()
|
||||||
|
.map(|x| x.name() == *name)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| format!("Input device '{name}' not found"))?,
|
||||||
|
None => host
|
||||||
|
.default_input_device()
|
||||||
|
.ok_or("No default input device")?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_config = device
|
||||||
|
.default_input_config()
|
||||||
|
.map_err(|e| format!("Failed to get default input config: {e}"))?
|
||||||
|
.config();
|
||||||
|
|
||||||
|
let producer = Arc::clone(&self.producer_in);
|
||||||
|
let inner_clone = Arc::clone(&state_inner);
|
||||||
|
|
||||||
|
let new_stream = device
|
||||||
|
.build_input_stream(
|
||||||
|
input_config,
|
||||||
|
move |data: &[f32], _| {
|
||||||
|
if let Ok(mut prod) = producer.lock() {
|
||||||
|
let _ = prod.push_slice(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
eprintln!("[vc] Input error: {err}. Attempting input stream recovery...");
|
||||||
|
if let Ok(mut lock) = inner_clone.session.lock() {
|
||||||
|
if let Some(session) = lock.as_mut() {
|
||||||
|
let target_device = session.current_input_device.clone();
|
||||||
|
if let Err(e) =
|
||||||
|
session.update_input_device(target_device, Arc::clone(&inner_clone))
|
||||||
|
{
|
||||||
|
eprintln!("[vc] Input recovery failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
new_stream
|
||||||
|
.play()
|
||||||
|
.map_err(|e| format!("Failed to play input stream: {e}"))?;
|
||||||
|
|
||||||
|
self.input_stream = new_stream;
|
||||||
|
self.current_input_device = device_name;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_output_device(
|
||||||
|
&mut self,
|
||||||
|
device_name: Option<String>,
|
||||||
|
state_inner: Arc<VoiceStateInner>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let device = match &device_name {
|
||||||
|
Some(name) => host
|
||||||
|
.output_devices()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.find(|d| {
|
||||||
|
d.description()
|
||||||
|
.ok()
|
||||||
|
.map(|x| x.name() == *name)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| format!("Output device '{name}' not found"))?,
|
||||||
|
None => host
|
||||||
|
.default_output_device()
|
||||||
|
.ok_or("No default output device")?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_config = device
|
||||||
|
.default_output_config()
|
||||||
|
.map_err(|e| format!("Failed to get default output config: {e}"))?
|
||||||
|
.config();
|
||||||
|
|
||||||
|
let consumer = Arc::clone(&self.consumer_out);
|
||||||
|
let new_stream = build_output_stream(&device, output_config, consumer, state_inner)?;
|
||||||
|
new_stream
|
||||||
|
.play()
|
||||||
|
.map_err(|e| format!("Failed to play output stream: {e}"))?;
|
||||||
|
|
||||||
|
if let Ok(mut active_stream) = self.output_stream.lock() {
|
||||||
|
*active_stream = new_stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_output_device = device_name;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DEVICES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_input_devices() -> Result<Vec<String>, String> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
Ok(host
|
||||||
|
.input_devices()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|d| d.description().ok().map(|x| x.name().to_string()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_output_devices() -> Result<Vec<String>, String> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
Ok(host
|
||||||
|
.output_devices()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|d| d.description().ok().map(|x| x.name().to_string()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DISCONNECT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), String> {
|
||||||
|
let mut lock = voice_state
|
||||||
|
.inner
|
||||||
|
.session
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(session) = lock.take() {
|
||||||
|
session.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let _ = session.input_stream.pause();
|
||||||
|
if let Ok(output) = session.output_stream.lock() {
|
||||||
|
let _ = output.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONNECT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn connect_to_vc(
|
||||||
|
hostname: String,
|
||||||
|
pin: u64,
|
||||||
|
config_state: State<'_, ConfigState>,
|
||||||
|
voice_state: State<'_, VoiceState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
disconnect_from_vc(voice_state.clone())?;
|
||||||
|
|
||||||
|
let config = config_state.0.lock().unwrap().clone();
|
||||||
|
let state_inner = Arc::clone(&voice_state.inner);
|
||||||
|
|
||||||
|
let socket = Arc::new(
|
||||||
|
UdpSocket::bind("0.0.0.0:0").map_err(|e| format!("Failed to bind UDP socket: {e}"))?,
|
||||||
|
);
|
||||||
|
socket
|
||||||
|
.connect(&hostname)
|
||||||
|
.map_err(|e| format!("Failed to connect UDP socket: {e}"))?;
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(5)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
socket
|
||||||
|
.send(&pin.to_be_bytes())
|
||||||
|
.map_err(|e| format!("Failed to send pin: {e}"))?;
|
||||||
|
|
||||||
|
let host = cpal::default_host();
|
||||||
|
|
||||||
|
let input_device = match &config.input_device_name {
|
||||||
|
Some(name) => host
|
||||||
|
.input_devices()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.find(|d| {
|
||||||
|
d.description()
|
||||||
|
.ok()
|
||||||
|
.map(|x| x.name() == *name)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.ok_or("Input device not found")?,
|
||||||
|
None => host
|
||||||
|
.default_input_device()
|
||||||
|
.ok_or("No default input device")?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_device = match &config.output_device_name {
|
||||||
|
Some(name) => host
|
||||||
|
.output_devices()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.find(|d| {
|
||||||
|
d.description()
|
||||||
|
.ok()
|
||||||
|
.map(|x| x.name() == *name)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.ok_or("Output device not found")?,
|
||||||
|
None => host
|
||||||
|
.default_output_device()
|
||||||
|
.ok_or("No default output device")?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
// Output Ring Buffer setup
|
||||||
|
let rb_out = HeapRb::<f32>::new(19200);
|
||||||
|
let (producer_out, consumer_out) = rb_out.split();
|
||||||
|
let mut producer_out = producer_out;
|
||||||
|
let shared_consumer_out = Arc::new(Mutex::new(consumer_out));
|
||||||
|
|
||||||
|
let output_config = output_device
|
||||||
|
.default_output_config()
|
||||||
|
.map_err(|e| format!("Failed to get default output config: {e}"))?
|
||||||
|
.config();
|
||||||
|
|
||||||
|
let output_stream = build_output_stream(
|
||||||
|
&output_device,
|
||||||
|
output_config,
|
||||||
|
Arc::clone(&shared_consumer_out),
|
||||||
|
Arc::clone(&state_inner),
|
||||||
|
)?;
|
||||||
|
output_stream
|
||||||
|
.play()
|
||||||
|
.map_err(|e| format!("Failed to start output: {e}"))?;
|
||||||
|
let output_stream = Arc::new(Mutex::new(output_stream));
|
||||||
|
|
||||||
|
// Input Ring Buffer setup
|
||||||
|
let rb_in = HeapRb::<f32>::new(19200);
|
||||||
|
let (producer_in, mut consumer_in) = rb_in.split();
|
||||||
|
let shared_producer_in = Arc::new(Mutex::new(producer_in));
|
||||||
|
|
||||||
|
let input_config = input_device
|
||||||
|
.default_input_config()
|
||||||
|
.map_err(|e| format!("Failed to get default input config: {e}"))?
|
||||||
|
.config();
|
||||||
|
|
||||||
|
let cb_producer = Arc::clone(&shared_producer_in);
|
||||||
|
let inner_input_err = Arc::clone(&state_inner);
|
||||||
|
|
||||||
|
let input_stream = input_device
|
||||||
|
.build_input_stream(
|
||||||
|
input_config,
|
||||||
|
move |data: &[f32], _| {
|
||||||
|
if let Ok(mut prod) = cb_producer.lock() {
|
||||||
|
let _ = prod.push_slice(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
eprintln!("[vc] Input error: {err}. Attempting input stream recovery...");
|
||||||
|
if let Ok(mut lock) = inner_input_err.session.lock() {
|
||||||
|
if let Some(session) = lock.as_mut() {
|
||||||
|
let target_device = session.current_input_device.clone();
|
||||||
|
if let Err(e) =
|
||||||
|
session.update_input_device(target_device, Arc::clone(&inner_input_err))
|
||||||
|
{
|
||||||
|
eprintln!("[vc] Input recovery failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// UDP Sender Thread
|
||||||
|
{
|
||||||
|
let input_socket = socket.clone();
|
||||||
|
let shutdown = shutdown.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut sequence = 0u32;
|
||||||
|
let mut net_packet = vec![0u8; HEADER_SIZE + PACKET_SAMPLES * 2];
|
||||||
|
let mut frame_buf = vec![0.0f32; PACKET_SAMPLES];
|
||||||
|
let mut hangover_counter = 0;
|
||||||
|
|
||||||
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
|
if consumer_in.occupied_len() >= PACKET_SAMPLES {
|
||||||
|
let _ = consumer_in.pop_slice(&mut frame_buf);
|
||||||
|
|
||||||
|
let sum_squares: f32 = frame_buf.iter().map(|&s| s * s).sum();
|
||||||
|
let rms = (sum_squares / PACKET_SAMPLES as f32).sqrt();
|
||||||
|
|
||||||
|
let is_speaking = if rms >= VAD_THRESHOLD {
|
||||||
|
hangover_counter = VAD_HANGOVER_FRAMES;
|
||||||
|
true
|
||||||
|
} else if hangover_counter > 0 {
|
||||||
|
hangover_counter -= 1;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_speaking {
|
||||||
|
net_packet[0..4].copy_from_slice(&sequence.to_be_bytes());
|
||||||
|
for (i, sample) in frame_buf.iter().enumerate() {
|
||||||
|
let pcm = (sample.clamp(-1.0, 1.0) * 32767.0) as i16;
|
||||||
|
let offset = HEADER_SIZE + i * 2;
|
||||||
|
net_packet[offset..offset + 2].copy_from_slice(&pcm.to_be_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = input_socket.send(&net_packet);
|
||||||
|
sequence = sequence.wrapping_add(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
thread::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDP Receiver Thread
|
||||||
|
{
|
||||||
|
let socket = socket.clone();
|
||||||
|
let shutdown = shutdown.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut packets: BTreeMap<u32, Vec<f32>> = BTreeMap::new();
|
||||||
|
let mut expected: Option<u32> = None;
|
||||||
|
let mut is_prebuffering = true;
|
||||||
|
let mut last_good_frame = vec![0.0f32; PACKET_SAMPLES];
|
||||||
|
let mut udp_buffer = [0u8; MAX_PACKET_SIZE];
|
||||||
|
let mut next_frame_time = Instant::now();
|
||||||
|
|
||||||
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
|
if let Ok(len) = socket.recv(&mut udp_buffer) {
|
||||||
|
if len > HEADER_SIZE {
|
||||||
|
let seq = u32::from_be_bytes(udp_buffer[0..4].try_into().unwrap());
|
||||||
|
let pcm = &udp_buffer[HEADER_SIZE..len];
|
||||||
|
let samples: Vec<f32> = pcm
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
packets.insert(seq, samples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_prebuffering {
|
||||||
|
if packets.len() >= INITIAL_PACKET_CUSHION {
|
||||||
|
expected = packets.keys().next().copied();
|
||||||
|
is_prebuffering = false;
|
||||||
|
next_frame_time = Instant::now();
|
||||||
|
} else {
|
||||||
|
thread::sleep(Duration::from_millis(1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if Instant::now() >= next_frame_time {
|
||||||
|
if let Some(seq) = expected {
|
||||||
|
if let Some(samples) = packets.remove(&seq) {
|
||||||
|
if samples.len() == PACKET_SAMPLES {
|
||||||
|
last_good_frame.copy_from_slice(&samples);
|
||||||
|
} else {
|
||||||
|
last_good_frame.clear();
|
||||||
|
last_good_frame
|
||||||
|
.extend(samples.iter().take(PACKET_SAMPLES).copied());
|
||||||
|
if last_good_frame.len() < PACKET_SAMPLES {
|
||||||
|
last_good_frame.resize(PACKET_SAMPLES, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = producer_out.push_slice(&last_good_frame);
|
||||||
|
expected = Some(seq.wrapping_add(1));
|
||||||
|
} else if packets.keys().any(|&x| x > seq) {
|
||||||
|
for sample in last_good_frame.iter_mut() {
|
||||||
|
*sample *= 0.65;
|
||||||
|
}
|
||||||
|
let _ = producer_out.push_slice(&last_good_frame);
|
||||||
|
expected = Some(seq.wrapping_add(1));
|
||||||
|
} else if packets.is_empty() {
|
||||||
|
is_prebuffering = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next_frame_time += Duration::from_millis(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input_stream
|
||||||
|
.play()
|
||||||
|
.map_err(|e| format!("Failed to start input: {e}"))?;
|
||||||
|
|
||||||
|
*state_inner.session.lock().unwrap() = Some(VoiceSession {
|
||||||
|
input_stream,
|
||||||
|
output_stream,
|
||||||
|
socket,
|
||||||
|
pin,
|
||||||
|
shutdown,
|
||||||
|
current_input_device: config.input_device_name,
|
||||||
|
current_output_device: config.output_device_name,
|
||||||
|
producer_in: shared_producer_in,
|
||||||
|
consumer_out: shared_consumer_out,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AUDIO CALLBACK
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fn build_output_stream(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: cpal::StreamConfig,
|
||||||
|
consumer: AudioConsumer,
|
||||||
|
state_inner: Arc<VoiceStateInner>,
|
||||||
|
) -> Result<cpal::Stream, String> {
|
||||||
|
let channels = config.channels as usize;
|
||||||
|
let mut last_sample = 0.0f32;
|
||||||
|
let inner_output_err = Arc::clone(&state_inner);
|
||||||
|
|
||||||
|
device
|
||||||
|
.build_output_stream(
|
||||||
|
config,
|
||||||
|
move |data: &mut [f32], _| {
|
||||||
|
let mut idx = 0;
|
||||||
|
let mut cons_guard = consumer.lock().ok();
|
||||||
|
|
||||||
|
while idx < data.len() {
|
||||||
|
let sample = cons_guard.as_mut().and_then(|c| c.try_pop());
|
||||||
|
if let Some(mono_sample) = sample {
|
||||||
|
last_sample = mono_sample;
|
||||||
|
for ch in 0..channels {
|
||||||
|
data[idx + ch] = mono_sample;
|
||||||
|
}
|
||||||
|
idx += channels;
|
||||||
|
} else {
|
||||||
|
while idx < data.len() {
|
||||||
|
last_sample *= 0.92;
|
||||||
|
for ch in 0..channels {
|
||||||
|
data[idx + ch] = last_sample;
|
||||||
|
}
|
||||||
|
idx += channels;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
eprintln!("[vc] Output error: {err}. Attempting output stream recovery...");
|
||||||
|
if let Ok(mut lock) = inner_output_err.session.lock() {
|
||||||
|
if let Some(session) = lock.as_mut() {
|
||||||
|
let target_device = session.current_output_device.clone();
|
||||||
|
if let Err(e) = session
|
||||||
|
.update_output_device(target_device, Arc::clone(&inner_output_err))
|
||||||
|
{
|
||||||
|
eprintln!("[vc] Output recovery failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for VoiceSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let _ = self.input_stream.pause();
|
||||||
|
if let Ok(output) = self.output_stream.lock() {
|
||||||
|
let _ = output.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("[vc] VoiceSession dropped and audio streams paused.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Config {
|
||||||
|
pub input_device_name: Option<String>,
|
||||||
|
pub output_device_name: Option<String>,
|
||||||
|
pub input_volume: u8,
|
||||||
|
pub output_volume: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
input_device_name: host
|
||||||
|
.default_input_device()
|
||||||
|
.and_then(|d| Some(d.description().ok()?.name().to_string())),
|
||||||
|
output_device_name: host
|
||||||
|
.default_output_device()
|
||||||
|
.and_then(|d| Some(d.description().ok()?.name().to_string())),
|
||||||
|
input_volume: 0,
|
||||||
|
output_volume: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ConfigState(pub Mutex<Config>);
|
||||||
|
|
||||||
|
fn config_file_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("Failed to resolve app data dir: {e}"))?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create app data dir: {e}"))?;
|
||||||
|
Ok(dir.join("config.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_config(state: State<ConfigState>, config: Config) -> Result<(), String> {
|
||||||
|
*state.0.lock().unwrap() = config;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_config(app: AppHandle, state: State<ConfigState>) -> Result<(), String> {
|
||||||
|
let path = config_file_path(&app)?;
|
||||||
|
let config = state.0.lock().unwrap();
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&*config)
|
||||||
|
.map_err(|e| format!("Failed to serialize config: {e}"))?;
|
||||||
|
|
||||||
|
fs::write(&path, json).map_err(|e| format!("Failed to write config: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_config(app: AppHandle, state: State<ConfigState>) -> Result<Config, String> {
|
||||||
|
let path = config_file_path(&app)?;
|
||||||
|
|
||||||
|
let config = if path.exists() {
|
||||||
|
let json = fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
|
||||||
|
serde_json::from_str(&json).map_err(|e| format!("Failed to parse config: {e}"))?
|
||||||
|
} else {
|
||||||
|
Config::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
*state.0.lock().unwrap() = config.clone();
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
|
pub mod audio;
|
||||||
|
pub mod config;
|
||||||
pub mod server_list;
|
pub mod server_list;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::commands::config::ConfigState;
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
@@ -5,11 +9,20 @@ pub mod types;
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.manage(ConfigState(Mutex::new(commands::config::Config::default())))
|
||||||
|
.manage(commands::audio::VoiceState::default())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::server_list::save_server_list,
|
commands::server_list::save_server_list,
|
||||||
commands::server_list::get_server_list,
|
commands::server_list::get_server_list,
|
||||||
commands::accounts::save_accounts,
|
commands::accounts::save_accounts,
|
||||||
commands::accounts::get_accounts,
|
commands::accounts::get_accounts,
|
||||||
|
commands::audio::list_input_devices,
|
||||||
|
commands::audio::list_output_devices,
|
||||||
|
commands::audio::connect_to_vc,
|
||||||
|
commands::audio::disconnect_from_vc,
|
||||||
|
commands::config::update_config,
|
||||||
|
commands::config::save_config,
|
||||||
|
commands::config::get_config,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import Sidebar from "./components/view/Sidebar";
|
import Sidebar from "./components/view/Sidebar";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import Page from "./components/page/PageView";
|
import Page from "./components/page/PageView";
|
||||||
|
import { SettingsDialog } from "./components/settings/SettingsDialog";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const appRef = useRef<Enclave | null>(null);
|
const appRef = useRef<Enclave | null>(null);
|
||||||
@@ -54,6 +55,8 @@ export default function App() {
|
|||||||
<Page appRef={appRef} />
|
<Page appRef={appRef} />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
|
<SettingsDialog appRef={appRef} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ export default class Enclave<P = Page> {
|
|||||||
public accounts?: AccountsFile;
|
public accounts?: AccountsFile;
|
||||||
public server?: EnclaveServer;
|
public server?: EnclaveServer;
|
||||||
public serverList: ServerList;
|
public serverList: ServerList;
|
||||||
|
public isSettingsOpen: boolean;
|
||||||
public forceRender: () => void;
|
public forceRender: () => void;
|
||||||
public page?: P;
|
public page?: P;
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
this.serverList = {};
|
this.serverList = {};
|
||||||
this.forceRender = () => {};
|
this.forceRender = () => {};
|
||||||
|
this.isSettingsOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init() {
|
public async init() {
|
||||||
@@ -204,6 +206,39 @@ export default class Enclave<P = Page> {
|
|||||||
server.messages[msg.channel_id][msg.message.id] = msg.message;
|
server.messages[msg.channel_id][msg.message.id] = msg.message;
|
||||||
this.forceRender();
|
this.forceRender();
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
case "JoinVoice":
|
||||||
|
server.voiceJoin && server.voiceJoin(msg.pin, msg.channel_id);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "UserJoinedVoice":
|
||||||
|
if (!server.voiceChatUsers[msg.channel_id])
|
||||||
|
server.voiceChatUsers[msg.channel_id] = [];
|
||||||
|
|
||||||
|
server.voiceChatUsers[msg.channel_id].push(msg.pubkey);
|
||||||
|
this.server?.getUsers([msg.pubkey]);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "UserLeftVoice":
|
||||||
|
if (!server.voiceChatUsers[msg.channel_id]) return;
|
||||||
|
|
||||||
|
server.voiceChatUsers[msg.channel_id] = server.voiceChatUsers[
|
||||||
|
msg.channel_id
|
||||||
|
].filter((v) => v !== msg.pubkey);
|
||||||
|
|
||||||
|
this.forceRender();
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "Speaking":
|
||||||
|
clearTimeout(server.voiceChatSpeakers[msg.pubkey]);
|
||||||
|
|
||||||
|
server.voiceChatSpeakers[msg.pubkey] = setTimeout(() => {
|
||||||
|
delete server.voiceChatSpeakers[msg.pubkey];
|
||||||
|
this.forceRender();
|
||||||
|
}, 800);
|
||||||
|
this.forceRender();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,25 @@ export type ClientMethod =
|
|||||||
method: "MessageDeleted";
|
method: "MessageDeleted";
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
message_id: string;
|
message_id: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "JoinVoice";
|
||||||
|
channel_id: string;
|
||||||
|
pin: bigint;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "UserJoinedVoice";
|
||||||
|
channel_id: string;
|
||||||
|
pubkey: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "UserLeftVoice";
|
||||||
|
channel_id: string;
|
||||||
|
pubkey: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "Speaking";
|
||||||
|
pubkey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServerMethod =
|
export type ServerMethod =
|
||||||
@@ -71,4 +90,11 @@ export type ServerMethod =
|
|||||||
method: "DeleteMessage";
|
method: "DeleteMessage";
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
message_id: string;
|
message_id: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "JoinVoice";
|
||||||
|
channel_id: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "LeaveVoice";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,14 +29,21 @@ export default class EnclaveServer {
|
|||||||
public isSecure: boolean;
|
public isSecure: boolean;
|
||||||
public websocket?: EnclaveWebSocket;
|
public websocket?: EnclaveWebSocket;
|
||||||
public meta?: ServerMeta;
|
public meta?: ServerMeta;
|
||||||
|
|
||||||
public messages: Record<string, Record<string, StoredMessage>>;
|
public messages: Record<string, Record<string, StoredMessage>>;
|
||||||
public users: Record<string, ClientMeta | null>;
|
public users: Record<string, ClientMeta | null>;
|
||||||
|
|
||||||
|
public voiceJoin?: (pin: bigint, channelId: string) => void;
|
||||||
|
public voiceChatUsers: Record<string, string[]>;
|
||||||
|
public voiceChatSpeakers: Record<string, NodeJS.Timeout>;
|
||||||
|
|
||||||
public constructor(hostname: string, isSecure: boolean) {
|
public constructor(hostname: string, isSecure: boolean) {
|
||||||
this.hostname = hostname;
|
this.hostname = hostname;
|
||||||
this.isSecure = isSecure;
|
this.isSecure = isSecure;
|
||||||
this.messages = {};
|
this.messages = {};
|
||||||
this.users = {};
|
this.users = {};
|
||||||
|
this.voiceChatUsers = {};
|
||||||
|
this.voiceChatSpeakers = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public disconnect() {
|
public disconnect() {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { ClientMethod, ServerMethod } from "./protocol";
|
import { ClientMethod, ServerMethod } from "./protocol";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +16,9 @@ export default class EnclaveWebSocket {
|
|||||||
this.onOpenQueue = new Array();
|
this.onOpenQueue = new Array();
|
||||||
|
|
||||||
this.websocket = new WebSocket(hostname);
|
this.websocket = new WebSocket(hostname);
|
||||||
|
this.websocket.onclose = () => {
|
||||||
|
invoke("disconnect_from_vc");
|
||||||
|
};
|
||||||
this.websocket.onopen = () => {
|
this.websocket.onopen = () => {
|
||||||
this.onOpenQueue.forEach((fun) => fun());
|
this.onOpenQueue.forEach((fun) => fun());
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Enclave from "@/app/app";
|
import Enclave from "@/app/app";
|
||||||
import { ChannelPageProps } from "@/components/page/PageView";
|
import { ChannelPageProps } from "@/components/page/PageView";
|
||||||
import TextChannel from "./TextChannel";
|
import TextChannel from "./TextChannel";
|
||||||
|
import VoiceChannel from "./VoiceChannel";
|
||||||
|
|
||||||
export default function ChannelPage({
|
export default function ChannelPage({
|
||||||
appRef,
|
appRef,
|
||||||
@@ -12,5 +13,7 @@ export default function ChannelPage({
|
|||||||
switch (appRef.current?.page?.channel.kind) {
|
switch (appRef.current?.page?.channel.kind) {
|
||||||
case "text":
|
case "text":
|
||||||
return <TextChannel appRef={appRef} />;
|
return <TextChannel appRef={appRef} />;
|
||||||
|
case "voice":
|
||||||
|
return <VoiceChannel appRef={appRef} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import Enclave from "@/app/app";
|
||||||
|
import { ChannelPageProps } from "../PageView";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Phone, PhoneOff } from "lucide-react";
|
||||||
|
|
||||||
|
export default function VoiceChannel({
|
||||||
|
appRef,
|
||||||
|
}: {
|
||||||
|
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
|
||||||
|
}) {
|
||||||
|
const channel = appRef.current?.page?.channel;
|
||||||
|
if (!channel) return null;
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const lastChannelId = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Leave current channel setup
|
||||||
|
const handleLeave = () => {
|
||||||
|
appRef.current?.server?.websocket?.send({ method: "LeaveVoice" });
|
||||||
|
invoke("disconnect_from_vc").catch(console.error);
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Explicit action to join the voice channel
|
||||||
|
const handleJoin = () => {
|
||||||
|
const hostname = appRef.current?.server?.hostname;
|
||||||
|
if (!hostname || channel.kind !== "voice" || !appRef.current?.server)
|
||||||
|
return;
|
||||||
|
|
||||||
|
appRef.current.server.voiceJoin = (pin, channelId) => {
|
||||||
|
invoke("connect_to_vc", { hostname, pin, channelId })
|
||||||
|
.then(() => setIsConnected(true))
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
appRef.current.server.websocket?.send({
|
||||||
|
method: "JoinVoice",
|
||||||
|
channel_id: channel.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up when channel changes or component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastChannelId.current !== channel.id) {
|
||||||
|
if (isConnected) {
|
||||||
|
invoke("disconnect_from_vc").catch(console.error);
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
lastChannelId.current = channel.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
invoke("disconnect_from_vc").catch(console.error);
|
||||||
|
};
|
||||||
|
}, [channel.id]);
|
||||||
|
|
||||||
|
const speakers =
|
||||||
|
appRef.current?.server?.voiceChatSpeakers &&
|
||||||
|
Object.keys(appRef.current?.server?.voiceChatSpeakers);
|
||||||
|
|
||||||
|
const users = appRef.current?.server?.voiceChatUsers[channel.id];
|
||||||
|
const usersLength = users?.length || 0;
|
||||||
|
|
||||||
|
// Dynamic grid column layout based on user count
|
||||||
|
const getGridCols = () => {
|
||||||
|
if (usersLength <= 3) return "grid-cols-1";
|
||||||
|
if (usersLength <= 6) return "grid-cols-3";
|
||||||
|
if (usersLength <= 9) return "grid-cols-3";
|
||||||
|
return "grid-cols-5";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Avatar sizing scales up for fewer participants
|
||||||
|
const getAvatarSize = () => {
|
||||||
|
if (usersLength <= 2) return "h-24 w-24 text-3xl";
|
||||||
|
if (usersLength <= 4) return "h-20 w-20 text-2xl";
|
||||||
|
return "h-14 w-14 text-lg";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen gap-2 pb-3">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border flex justify-between items-center">
|
||||||
|
<h2>{channel.name}</h2>
|
||||||
|
<span className="text-xs">
|
||||||
|
{usersLength} {usersLength === 1 ? "Participant" : "Participants"}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Grid */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid p-4 w-full gap-4 flex-1 auto-rows-fr bg-background/50 rounded-xl overflow-y-auto",
|
||||||
|
getGridCols(),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{users?.map((pubkey) => {
|
||||||
|
const user = appRef.current?.server?.users[pubkey];
|
||||||
|
const isSpeaking = speakers?.includes(pubkey);
|
||||||
|
const displayName = user?.displayName || "Unknown User";
|
||||||
|
const fallbackLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pubkey}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center justify-center p-4 rounded-xl bg-card/80 transition-all duration-150 overflow-hidden group h-full",
|
||||||
|
isSpeaking
|
||||||
|
? "ring-2 ring-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.25)] bg-card"
|
||||||
|
: "ring-1 ring-border/60 hover:ring-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Avatar Container */}
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<Avatar
|
||||||
|
className={cn("transition-all duration-200", getAvatarSize())}
|
||||||
|
>
|
||||||
|
<AvatarImage
|
||||||
|
src={user?.avatar}
|
||||||
|
alt={displayName}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="bg-muted font-bold text-muted-foreground">
|
||||||
|
{fallbackLetter}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name Plate */}
|
||||||
|
<div className="absolute bottom-3 left-3 max-w-[85%] truncate bg-background/80 backdrop-blur-md text-foreground py-1 px-2.5 rounded-md border border-border/50 text-xs font-medium shadow-sm">
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Control Bar */}
|
||||||
|
<footer className="flex items-center justify-center px-4 py-2 border-t border-border/40 bg-card/50 rounded-xl mx-2">
|
||||||
|
{isConnected ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleLeave}
|
||||||
|
className="flex items-center gap-2 font-medium px-6"
|
||||||
|
>
|
||||||
|
<PhoneOff className="w-4 h-4" />
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleJoin}
|
||||||
|
className="flex items-center gap-2 font-medium px-6 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
|
Join Voice
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Palette } from "lucide-react";
|
||||||
|
|
||||||
|
export function AppearanceSettings() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 w-full">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Palette /> Appearance
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Theme and display settings go here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Volume2, Mic, Speaker } from "lucide-react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { getConfig, updateConfig, saveConfig, Config } from "@/lib/config";
|
||||||
|
|
||||||
|
export function AudioVideoSettings() {
|
||||||
|
const [config, setConfig] = useState<Config | null>(null);
|
||||||
|
const [inputDevices, setInputDevices] = useState<string[]>([]);
|
||||||
|
const [outputDevices, setOutputDevices] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getConfig().then(setConfig);
|
||||||
|
invoke<string[]>("list_input_devices").then(setInputDevices);
|
||||||
|
invoke<string[]>("list_output_devices").then(setOutputDevices);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleChange(patch: Partial<Config>) {
|
||||||
|
if (!config) return;
|
||||||
|
const updated = { ...config, ...patch };
|
||||||
|
setConfig(updated);
|
||||||
|
await updateConfig(updated);
|
||||||
|
await saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 w-full">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Volume2 /> Audio & Video
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
Input
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Input device</Label>
|
||||||
|
<Select
|
||||||
|
value={config.inputDeviceName ?? undefined}
|
||||||
|
onValueChange={(value) => handleChange({ inputDeviceName: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a microphone" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{inputDevices.map((device) => (
|
||||||
|
<SelectItem key={device} value={device}>
|
||||||
|
{device}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Input volume</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{config.inputVolume}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[config.inputVolume]}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleChange({ inputVolume: value as any })
|
||||||
|
}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Speaker className="h-4 w-4" />
|
||||||
|
Output
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Output device</Label>
|
||||||
|
<Select
|
||||||
|
value={config.outputDeviceName ?? undefined}
|
||||||
|
onValueChange={(value) => handleChange({ outputDeviceName: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a speaker" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{outputDevices.map((device) => (
|
||||||
|
<SelectItem key={device} value={device}>
|
||||||
|
{device}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Output volume</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{config.outputVolume}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[config.outputVolume]}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleChange({ outputVolume: value as any })
|
||||||
|
}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { User } from "lucide-react";
|
||||||
|
|
||||||
|
export function ProfileSettings() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 w-full">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<User /> Profile
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Display name and avatar settings go here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { User, Palette, Volume2 } from "lucide-react";
|
||||||
|
import { ProfileSettings } from "./ProfileSettings";
|
||||||
|
import { AppearanceSettings } from "./AppearanceSettings";
|
||||||
|
import { AudioVideoSettings } from "./AudioVideoSettings";
|
||||||
|
import Enclave from "@/app/app";
|
||||||
|
|
||||||
|
type SettingsCategory = "profile" | "appearance" | "audio-video";
|
||||||
|
|
||||||
|
const CATEGORIES: {
|
||||||
|
id: SettingsCategory;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}[] = [
|
||||||
|
{ id: "profile", label: "Profile", icon: User },
|
||||||
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||||
|
{ id: "audio-video", label: "Audio & Video", icon: Volume2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsDialog({
|
||||||
|
appRef,
|
||||||
|
}: {
|
||||||
|
appRef: React.RefObject<Enclave | null>;
|
||||||
|
}) {
|
||||||
|
const [category, setCategory] = useState<SettingsCategory>("profile");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={appRef.current?.isSettingsOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!appRef.current) return;
|
||||||
|
|
||||||
|
appRef.current.isSettingsOpen = open;
|
||||||
|
appRef.current.forceRender();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className="flex h-[85vh] w-[90vw] max-w-5xl gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||||
|
showCloseButton
|
||||||
|
>
|
||||||
|
<div className="flex w-56 shrink-0 flex-col border-r bg-muted/30 p-3">
|
||||||
|
<span className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Settings
|
||||||
|
</span>
|
||||||
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
{CATEGORIES.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setCategory(id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||||
|
category === id
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{category === "profile" && <ProfileSettings />}
|
||||||
|
{category === "appearance" && <AppearanceSettings />}
|
||||||
|
{category === "audio-video" && <AudioVideoSettings />}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
@@ -29,12 +29,12 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Backdrop
|
<DialogPrimitive.Backdrop
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 backdrop-blur-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
@@ -43,7 +43,7 @@ function DialogContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: DialogPrimitive.Popup.Props & {
|
}: DialogPrimitive.Popup.Props & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
@@ -52,7 +52,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -68,14 +68,13 @@ function DialogContent({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Popup>
|
</DialogPrimitive.Popup>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -85,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-2", className)}
|
className={cn("flex flex-col gap-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({
|
function DialogFooter({
|
||||||
@@ -94,14 +93,14 @@ function DialogFooter({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -112,7 +111,7 @@ function DialogFooter({
|
|||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
@@ -121,11 +120,11 @@ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
|||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading text-base leading-none font-medium",
|
"font-heading text-base leading-none font-medium",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
@@ -137,11 +136,11 @@ function DialogDescription({
|
|||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -155,4 +154,4 @@ export {
|
|||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: SliderPrimitive.Root.Props) {
|
||||||
|
const _values = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
thumbAlignment="edge"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Indicator
|
||||||
|
data-slot="slider-range"
|
||||||
|
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Control>
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
@@ -41,7 +41,16 @@ export default function AccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-muted-foreground">
|
<div className="flex items-center text-muted-foreground">
|
||||||
<Button variant="ghost" className="size-10">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-10"
|
||||||
|
onClick={() => {
|
||||||
|
if (!appRef.current) return;
|
||||||
|
|
||||||
|
appRef.current.isSettingsOpen = !appRef.current?.isSettingsOpen;
|
||||||
|
appRef.current.forceRender();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Settings2Icon className="size-5.5" />
|
<Settings2Icon className="size-5.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import Enclave from "@/app/app";
|
import Enclave from "@/app/app";
|
||||||
import { Channel, ChannelKind } from "@/lib/types";
|
import { Channel, ChannelKind } from "@/lib/types";
|
||||||
import { ChevronDown, ChevronUp, HashIcon } from "lucide-react";
|
import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import AccountCard from "./AccountCard";
|
import AccountCard from "./AccountCard";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
|
export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
case "category":
|
||||||
|
return null;
|
||||||
case "text":
|
case "text":
|
||||||
return <HashIcon className="p-0.5" />;
|
return <HashIcon className="p-0.5" />;
|
||||||
default:
|
case "voice":
|
||||||
return null;
|
return <Volume2Icon className="p-0.5" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
outputDeviceName: string | null;
|
||||||
|
inputDeviceName: string | null;
|
||||||
|
inputVolume: number;
|
||||||
|
outputVolume: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateConfig(config: Config): Promise<void> {
|
||||||
|
await invoke("update_config", { config });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConfig(): Promise<void> {
|
||||||
|
await invoke("save_config");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfig(): Promise<Config> {
|
||||||
|
return await invoke("get_config");
|
||||||
|
}
|
||||||
+3
-1
@@ -24,4 +24,6 @@ export interface StoredMessage extends MessageData {
|
|||||||
export type Channel = { id: string; name: string } & ChannelKind;
|
export type Channel = { id: string; name: string } & ChannelKind;
|
||||||
|
|
||||||
export type ChannelKind =
|
export type ChannelKind =
|
||||||
{ kind: "text" } | { kind: "category"; channels: Channel[] };
|
| { kind: "category"; channels: Channel[] }
|
||||||
|
| { kind: "voice"; max_users: number }
|
||||||
|
| { kind: "text" };
|
||||||
|
|||||||
Reference in New Issue
Block a user