started working on voice chat
This commit is contained in:
180
src-tauri/Cargo.lock
generated
180
src-tauri/Cargo.lock
generated
@@ -32,6 +32,28 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
|
||||||
|
dependencies = [
|
||||||
|
"alsa-sys",
|
||||||
|
"bitflags 2.10.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]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -551,6 +573,51 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-rs"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"libc",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpal"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
|
||||||
|
dependencies = [
|
||||||
|
"alsa",
|
||||||
|
"coreaudio-rs",
|
||||||
|
"dasp_sample",
|
||||||
|
"jack",
|
||||||
|
"jni",
|
||||||
|
"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",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -666,6 +733,12 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
@@ -1020,6 +1093,7 @@ dependencies = [
|
|||||||
name = "frangipane"
|
name = "frangipane"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"cpal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -1033,6 +1107,7 @@ dependencies = [
|
|||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-upload",
|
"tauri-plugin-upload",
|
||||||
"tauri-plugin-websocket",
|
"tauri-plugin-websocket",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1853,6 +1928,33 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jack"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73213dab741ae0a4623824289625611d11bb8254ad1fdefc84e480f7632aa528"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"jack-sys",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jack-sys"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"libloading",
|
||||||
|
"log",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "javascriptcore-rs"
|
name = "javascriptcore-rs"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -2066,6 +2168,15 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@@ -2235,6 +2346,17 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
|
[[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.111",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2297,6 +2419,31 @@ dependencies = [
|
|||||||
"objc2-quartz-core",
|
"objc2-quartz-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-audio-toolbox"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"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 = [
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-cloud-kit"
|
name = "objc2-cloud-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2308,6 +2455,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.10.0",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-data"
|
name = "objc2-core-data"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2326,7 +2496,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
|
"block2",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4724,9 +4896,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.43"
|
version = "0.1.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
@@ -4746,9 +4918,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.35"
|
version = "0.1.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -30,4 +30,6 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
cpal = { version = "0.17.1", features = ["jack"] }
|
||||||
|
tracing = "0.1.44"
|
||||||
|
|
||||||
|
|||||||
8
src-tauri/Info.plist
Normal file
8
src-tauri/Info.plist
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Request microphone access for WebRTC</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -57,4 +57,5 @@
|
|||||||
"os:default",
|
"os:default",
|
||||||
"notification:default"
|
"notification:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
<!-- AndroidTV support -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
@@ -35,4 +37,4 @@
|
|||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
175
src-tauri/src/audio.rs
Normal file
175
src-tauri/src/audio.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::SampleFormat;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
pub struct AudioState {
|
||||||
|
stream: Arc<Mutex<Option<cpal::Stream>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
stream: Arc::new(Mutex::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct MicConfig {
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn start_microphone(app: AppHandle, state: tauri::State<AudioState>) -> Result<(), String> {
|
||||||
|
#[cfg(all(any(
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "dragonfly",
|
||||||
|
target_os = "freebsd",
|
||||||
|
target_os = "netbsd"
|
||||||
|
)))]
|
||||||
|
let host = cpal::host_from_id(
|
||||||
|
cpal::available_hosts()
|
||||||
|
.into_iter()
|
||||||
|
.find(|id| *id == cpal::HostId::Jack)
|
||||||
|
.expect("jack host unavailable"),
|
||||||
|
)
|
||||||
|
.unwrap_or(
|
||||||
|
cpal::host_from_id(*cpal::available_hosts().first().expect("no host available"))
|
||||||
|
.expect("host not available"),
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(any(not(any(
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "dragonfly",
|
||||||
|
target_os = "freebsd",
|
||||||
|
target_os = "netbsd"
|
||||||
|
))))]
|
||||||
|
let host = cpal::default_host();
|
||||||
|
|
||||||
|
let device = host
|
||||||
|
.default_input_device()
|
||||||
|
.ok_or("No input device available")?;
|
||||||
|
|
||||||
|
let config = device
|
||||||
|
.default_input_config()
|
||||||
|
.map_err(|e| format!("Failed to get config: {}", e))?;
|
||||||
|
|
||||||
|
println!("Microphone Config: {:?}", config);
|
||||||
|
let sample_rate = config.sample_rate();
|
||||||
|
let device_channels = config.channels() as usize;
|
||||||
|
let channels = 1; // NOTE: temporary
|
||||||
|
|
||||||
|
let buffer_threshold = (sample_rate as usize / 10) * 2;
|
||||||
|
|
||||||
|
app.emit(
|
||||||
|
"microphone-config",
|
||||||
|
MicConfig {
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let sample_format = config.sample_format();
|
||||||
|
let stream_config: cpal::StreamConfig = config.into();
|
||||||
|
|
||||||
|
let err_fn = |err| eprintln!("Stream error: {}", err);
|
||||||
|
let app_handle = app.clone();
|
||||||
|
|
||||||
|
let mut emit_if_full = move |buffer: &mut Vec<u8>| {
|
||||||
|
if buffer.len() >= buffer_threshold {
|
||||||
|
let payload = buffer.clone();
|
||||||
|
let _ = app_handle.emit("microphone-data", payload);
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
|
||||||
|
let stream = match sample_format {
|
||||||
|
SampleFormat::F32 => {
|
||||||
|
let mut local_buffer = Vec::with_capacity(buffer_threshold * 2);
|
||||||
|
|
||||||
|
device.build_input_stream(
|
||||||
|
&stream_config,
|
||||||
|
move |data: &[f32], _| {
|
||||||
|
for frame in data.chunks(device_channels) {
|
||||||
|
let sample = frame[0]; // Take first channel
|
||||||
|
let s = sample.clamp(-1.0, 1.0);
|
||||||
|
let v = if s >= 0.0 {
|
||||||
|
(s * 32767.0) as i16
|
||||||
|
} else {
|
||||||
|
(s * 32768.0) as i16
|
||||||
|
};
|
||||||
|
local_buffer.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only emit if we have enough data
|
||||||
|
if local_buffer.len() >= buffer_threshold {
|
||||||
|
let _ = app_handle.emit("microphone-data", &local_buffer);
|
||||||
|
local_buffer.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SampleFormat::I16 => {
|
||||||
|
let mut local_buffer = Vec::with_capacity(buffer_threshold * 2);
|
||||||
|
|
||||||
|
device.build_input_stream(
|
||||||
|
&stream_config,
|
||||||
|
move |data: &[i16], _| {
|
||||||
|
for frame in data.chunks(device_channels) {
|
||||||
|
let sample = frame[0];
|
||||||
|
local_buffer.extend_from_slice(&sample.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
if local_buffer.len() >= buffer_threshold {
|
||||||
|
let _ = app_handle.emit("microphone-data", &local_buffer);
|
||||||
|
local_buffer.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SampleFormat::U16 => {
|
||||||
|
let mut local_buffer = Vec::with_capacity(buffer_threshold * 2);
|
||||||
|
|
||||||
|
device.build_input_stream(
|
||||||
|
&stream_config,
|
||||||
|
move |data: &[u16], _| {
|
||||||
|
for frame in data.chunks(device_channels) {
|
||||||
|
let sample = frame[0];
|
||||||
|
let v = (sample as i32 - 32768) as i16;
|
||||||
|
local_buffer.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
if local_buffer.len() >= buffer_threshold {
|
||||||
|
let _ = app_handle.emit("microphone-data", &local_buffer);
|
||||||
|
local_buffer.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => return Err("Unsupported sample format".to_string()),
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
stream.play().map_err(|e| e.to_string())?;
|
||||||
|
*state.stream.lock().unwrap() = Some(stream);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stop_microphone(state: tauri::State<AudioState>) -> Result<(), String> {
|
||||||
|
let mut stream_guard = state.stream.lock().unwrap();
|
||||||
|
*stream_guard = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
mod audio;
|
||||||
|
use audio::{start_microphone, stop_microphone};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn log(message: &str) {
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
println!("[JS] {}", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.manage(audio::AudioState::new())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
@@ -16,7 +19,11 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![greet])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
start_microphone,
|
||||||
|
stop_microphone,
|
||||||
|
log
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod audio;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
frangipane_lib::run()
|
frangipane_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
7
src-tauri/tauri.android.conf.json
Normal file
7
src-tauri/tauri.android.conf.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"android": {
|
||||||
|
"minSdkVersion": 26
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
<VersionWarningModal v-if="showVersionWarningModal" :appVersion="appVersion" :backendVersion="backendVersion"
|
<VersionWarningModal v-if="showVersionWarningModal" :appVersion="appVersion" :backendVersion="backendVersion"
|
||||||
:expectedBackendVersion="expectedBackendVersion" @close="showVersionWarningModal = false" />
|
:expectedBackendVersion="expectedBackendVersion" @close="showVersionWarningModal = false" />
|
||||||
|
|
||||||
|
<VoiceControl />
|
||||||
|
|
||||||
<footer v-if="!$route.meta.hideNavbar">
|
<footer v-if="!$route.meta.hideNavbar">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</footer>
|
</footer>
|
||||||
@@ -40,6 +42,7 @@ import { apiFetch } from './api/client'
|
|||||||
import { VersionResponse } from './types'
|
import { VersionResponse } from './types'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import { platform } from '@tauri-apps/plugin-os';
|
import { platform } from '@tauri-apps/plugin-os';
|
||||||
|
import VoiceControl from './components/VoiceControl.vue'
|
||||||
|
|
||||||
const currentPlatform = ref('')
|
const currentPlatform = ref('')
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,24 @@ import { apiFetch } from './client'
|
|||||||
import type { Message } from '../types'
|
import type { Message } from '../types'
|
||||||
|
|
||||||
export function fetchMessages(roomUuid: string, before?: string, limit: number = 30) {
|
export function fetchMessages(roomUuid: string, before?: string, limit: number = 30) {
|
||||||
let url = `/messages/${roomUuid}?limit=${limit}`;
|
let url = `/messages/${roomUuid}?limit=${limit}`;
|
||||||
if (before) {
|
if (before) {
|
||||||
url += `&before=${before}`;
|
url += `&before=${before}`;
|
||||||
}
|
}
|
||||||
return apiFetch<Message[]>(url);
|
return apiFetch<Message[]>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendMessage(roomUuid: string, content: string) {
|
export function sendMessage(roomUuid: string, content: string) {
|
||||||
return apiFetch<Message>(`/messages/${roomUuid}`, {
|
return apiFetch<Message>(`/messages/${roomUuid}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message_type: 'text',
|
message_type: 'text',
|
||||||
content,
|
content,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWsToken(): Promise<string> {
|
export async function getWsToken(): Promise<string> {
|
||||||
const res = await apiFetch<{ token: string }>(`/ws/messages/issue-token`);
|
const res = await apiFetch<{ token: string }>(`/ws/issue-token`);
|
||||||
return res.token;
|
return res.token;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@
|
|||||||
<i class="fa-solid fa-users"></i>
|
<i class="fa-solid fa-users"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button class="invite-btn" @click="toggleVoice" :class="{ 'active-voice': isCurrentRoomVoice }"
|
||||||
|
title="Join Voice Chat">
|
||||||
|
<i class="fa-solid" :class="isCurrentRoomVoice ? 'fa-phone-slash' : 'fa-phone'"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div v-if="connectionError" class="connection-error">
|
<div v-if="connectionError" class="connection-error">
|
||||||
<p>{{ connectionError }}</p>
|
<p>{{ connectionError }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,6 +73,7 @@ import { getAuthData } from "../store.ts";
|
|||||||
import { fetchRoomInfo } from "../api/rooms.ts";
|
import { fetchRoomInfo } from "../api/rooms.ts";
|
||||||
import { useFluent } from 'fluent-vue';
|
import { useFluent } from 'fluent-vue';
|
||||||
import { sendNotification } from '@tauri-apps/plugin-notification';
|
import { sendNotification } from '@tauri-apps/plugin-notification';
|
||||||
|
import { voiceActions, voiceState } from "../voice";
|
||||||
|
|
||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
|
||||||
@@ -109,6 +115,10 @@ const handleRoomChanged = () => {
|
|||||||
emit('room-action');
|
emit('room-action');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isCurrentRoomVoice = computed(() => {
|
||||||
|
return voiceState.currentRoomUuid === props.uuid && voiceState.status === 'connected';
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await connectGlobalWebSocket();
|
await connectGlobalWebSocket();
|
||||||
|
|
||||||
@@ -172,7 +182,7 @@ async function connectGlobalWebSocket() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get a one-time token for the connection
|
// Get a one-time token for the connection
|
||||||
const res = await apiFetch<{ token: string }>('/ws/messages/issue-token');
|
const res = await apiFetch<{ token: string }>('/ws/issue-token');
|
||||||
const wsToken = res.token;
|
const wsToken = res.token;
|
||||||
|
|
||||||
const url = `${API_WS}/messages?token=${wsToken}`;
|
const url = `${API_WS}/messages?token=${wsToken}`;
|
||||||
@@ -309,6 +319,14 @@ async function onSend(content: string) {
|
|||||||
if (props.uuid === 'none') return;
|
if (props.uuid === 'none') return;
|
||||||
await sendMessage(props.uuid, content);
|
await sendMessage(props.uuid, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleVoice() {
|
||||||
|
if (isCurrentRoomVoice.value) {
|
||||||
|
await voiceActions.leaveRoom();
|
||||||
|
} else {
|
||||||
|
await voiceActions.joinRoom(props.uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -457,4 +475,8 @@ async function onSend(content: string) {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-voice {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
154
src/components/VoiceControl.vue
Normal file
154
src/components/VoiceControl.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="voiceState.status !== 'disconnected'" class="voice-bar">
|
||||||
|
<div class="voice-info">
|
||||||
|
<div class="status-indicator" :class="voiceState.status"></div>
|
||||||
|
<div class="details">
|
||||||
|
<span class="room-label">Voice Connected</span>
|
||||||
|
<span class="speaking-label" v-if="speakersList.length > 0">
|
||||||
|
Speaking: {{ speakersList.length }}
|
||||||
|
</span>
|
||||||
|
<span class="speaking-label" v-else>Room Quiet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="voice-controls">
|
||||||
|
<button class="control-btn" :class="{ 'active': voiceState.isMuted }" @click="voiceActions.toggleMute()">
|
||||||
|
<i class="fa-solid" :class="voiceState.isMuted ? 'fa-microphone-slash' : 'fa-microphone'"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="control-btn hangup" @click="voiceActions.leaveRoom()">
|
||||||
|
<i class="fa-solid fa-phone-slash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { voiceState, voiceActions } from '../voice'; // Ensure this path points to your voice.ts
|
||||||
|
|
||||||
|
const speakersList = computed(() => Array.from(voiceState.speakingUsers));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.device-select {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-width: 150px;
|
||||||
|
opacity: 0.8;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-select:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-bar {
|
||||||
|
background-color: var(--panel-accent);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 50px;
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.connected {
|
||||||
|
background-color: #4ade80;
|
||||||
|
/* Green */
|
||||||
|
box-shadow: 0 0 5px #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.connecting {
|
||||||
|
background-color: #facc15;
|
||||||
|
/* Yellow */
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.error {
|
||||||
|
background-color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaking-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.active {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.hangup {
|
||||||
|
color: #f87171;
|
||||||
|
border-color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.hangup:hover {
|
||||||
|
background: #f87171;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
src/main.ts
28
src/main.ts
@@ -3,15 +3,13 @@ import router from './router.ts'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { validateToken, initTheme } from './store.ts'
|
import { validateToken, initTheme } from './store.ts'
|
||||||
import { fluent, setLanguage } from './i18n'
|
import { fluent, setLanguage } from './i18n'
|
||||||
// import {
|
|
||||||
// createChannel,
|
|
||||||
// Importance,
|
|
||||||
// Visibility,
|
|
||||||
// } from '@tauri-apps/plugin-notification';
|
|
||||||
|
|
||||||
|
|
||||||
import './base.css'
|
import './base.css'
|
||||||
import { getLocalePreference } from './store.ts'
|
import { getLocalePreference } from './store.ts'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
|
export function logJS(msg: any) {
|
||||||
|
invoke('log', { message: typeof msg === 'string' ? msg : JSON.stringify(msg) }).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
await validateToken()
|
await validateToken()
|
||||||
@@ -43,14 +41,22 @@ async function init() {
|
|||||||
// sound: 'notification_sound',
|
// sound: 'notification_sound',
|
||||||
// });
|
// });
|
||||||
|
|
||||||
|
// window.addEventListener("error", (event) => {
|
||||||
|
// logJS(`Uncaught JS Error: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`);
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
// logJS(`Unhandled Promise Rejection: ${JSON.stringify(event.reason)}`);
|
||||||
|
// });
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
}
|
}
|
||||||
|
|
||||||
init()
|
init()
|
||||||
|
|
||||||
export const API = 'http://127.0.0.1:8080'
|
// export const API = 'http://127.0.0.1:8080'
|
||||||
// export const API = 'http://192.168.1.183:8080'
|
// export const API = 'http://192.168.1.183:8080'
|
||||||
// export const API = 'https://alatreon.org/frangipane'
|
export const API = 'https://alatreon.org/frangipane'
|
||||||
export const API_WS = 'ws://127.0.0.1:8080/ws'
|
// export const API_WS = 'ws://127.0.0.1:8080/ws'
|
||||||
// export const API_WS = 'ws://192.168.1.183:8080/ws'
|
// export const API_WS = 'ws://192.168.1.183:8080/ws'
|
||||||
// export const API_WS = 'wss://alatreon.org/frangipane/ws'
|
export const API_WS = 'wss://alatreon.org/frangipane/ws'
|
||||||
|
|||||||
238
src/store.ts
238
src/store.ts
@@ -14,153 +14,153 @@ let store: Store | null = null
|
|||||||
export const initAuth = getAuthData
|
export const initAuth = getAuthData
|
||||||
|
|
||||||
async function getStore() {
|
async function getStore() {
|
||||||
if (!store) store = await load('store.json')
|
if (!store) store = await load('store.json')
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAuthData() {
|
export async function getAuthData() {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
const token = await s.get<string>('token')
|
const token = await s.get<string>('token')
|
||||||
const user = await s.get<User>('user')
|
const user = await s.get<User>('user')
|
||||||
return { token: token || null, user: user || null, isAuthenticated: !!token }
|
return { token: token || null, user: user || null, isAuthenticated: !!token }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveAuthData(token: string, user: User) {
|
export async function saveAuthData(token: string, user: User) {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
await s.set('token', token)
|
await s.set('token', token)
|
||||||
await s.set('user', user)
|
await s.set('user', user)
|
||||||
await s.save()
|
await s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAuthData() {
|
export async function clearAuthData() {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
s.clear()
|
s.clear()
|
||||||
await s.save()
|
await s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLocalUser(newData: UpdateUserResponse, uuid?: string) {
|
export async function updateLocalUser(newData: UpdateUserResponse, uuid?: string) {
|
||||||
const updatedUser = {
|
const updatedUser = {
|
||||||
username: newData.username,
|
username: newData.username,
|
||||||
email: newData.email,
|
email: newData.email,
|
||||||
uuid,
|
uuid,
|
||||||
avatar_url: `${getAvatar(uuid || '')}?t=${Date.now()}`
|
avatar_url: `${getAvatar(uuid || '')}?t=${Date.now()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
await s.set('user', updatedUser)
|
await s.set('user', updatedUser)
|
||||||
await s.save()
|
await s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshLocalUser() {
|
export async function refreshLocalUser() {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
const user = await s.get<User>('user')
|
const user = await s.get<User>('user')
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
user.avatar_url = `${getAvatar(user.uuid)}?t=${Date.now()}`
|
user.avatar_url = `${getAvatar(user.uuid)}?t=${Date.now()}`
|
||||||
|
|
||||||
await s.set('user', user)
|
await s.set('user', user)
|
||||||
await s.save()
|
await s.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setLastRoom(uuid: string) {
|
export async function setLastRoom(uuid: string) {
|
||||||
if (!uuid || uuid === 'none') return
|
if (!uuid || uuid === 'none') return
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
await s.set('last_room_uuid', uuid)
|
await s.set('last_room_uuid', uuid)
|
||||||
await s.save()
|
await s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLastRoom(): Promise<string | null> {
|
export async function getLastRoom(): Promise<string | null> {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
return (await s.get<string>('last_room_uuid')) ?? null
|
return (await s.get<string>('last_room_uuid')) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveLocalePreference(locale: string) {
|
export async function saveLocalePreference(locale: string) {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
await s.set('language', locale)
|
await s.set('language', locale)
|
||||||
await s.save()
|
await s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLocalePreference(): Promise<string | null> {
|
export async function getLocalePreference(): Promise<string | null> {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
return (await s.get<string>('language')) ?? null
|
return (await s.get<string>('language')) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function login(email: string, username: string, password: string) {
|
export async function login(email: string, username: string, password: string) {
|
||||||
const res: LoginResponse = await apiFetch('/login', {
|
const res: LoginResponse = await apiFetch('/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, username, password }),
|
body: JSON.stringify({ email, username, password }),
|
||||||
})
|
})
|
||||||
|
|
||||||
let user: User = {
|
let user: User = {
|
||||||
uuid: res.uuid,
|
uuid: res.uuid,
|
||||||
username: res.username,
|
username: res.username,
|
||||||
email: res.email,
|
email: res.email,
|
||||||
avatar_url: getAvatar(res.uuid)
|
avatar_url: getAvatar(res.uuid)
|
||||||
};
|
};
|
||||||
await saveAuthData(res.token, user)
|
await saveAuthData(res.token, user)
|
||||||
return { token: res.token, uuid: res.uuid, isAuthenticated: true }
|
return { token: res.token, uuid: res.uuid, isAuthenticated: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
await clearAuthData()
|
await clearAuthData()
|
||||||
return { token: null, uuid: null, isAuthenticated: false }
|
return { token: null, uuid: null, isAuthenticated: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateToken(): Promise<boolean> {
|
export async function validateToken(): Promise<boolean> {
|
||||||
const auth = await getAuthData()
|
const auth = await getAuthData()
|
||||||
if (!auth.token) return false
|
if (!auth.token) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiFetch('/validate-token')
|
await apiFetch('/validate-token')
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function register(email: string, username: string, password: string) {
|
export async function register(email: string, username: string, password: string) {
|
||||||
const response: LoginResponse = await apiFetch('/register', {
|
const response: LoginResponse = await apiFetch('/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, username, password })
|
body: JSON.stringify({ email, username, password })
|
||||||
});
|
});
|
||||||
|
|
||||||
await login(email, username, password)
|
await login(email, username, password)
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = ref<FriendRequest[]>([])
|
const requests = ref<FriendRequest[]>([])
|
||||||
const invites = ref<RoomInvite[]>([])
|
const invites = ref<RoomInvite[]>([])
|
||||||
|
|
||||||
export function useNotifications() {
|
export function useNotifications() {
|
||||||
const totalCount = computed(() => requests.value.length + invites.value.length)
|
const totalCount = computed(() => requests.value.length + invites.value.length)
|
||||||
|
|
||||||
async function refreshNotifications() {
|
async function refreshNotifications() {
|
||||||
const auth = await getAuthData()
|
const auth = await getAuthData()
|
||||||
if (!auth.token) {
|
if (!auth.token) {
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [fReqs, rInvs] = await Promise.all([
|
||||||
|
fetchFriendRequests(),
|
||||||
|
fetchRoomInvites()
|
||||||
|
])
|
||||||
|
requests.value = fReqs
|
||||||
|
invites.value = rInvs
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch notifications", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return {
|
||||||
const [fReqs, rInvs] = await Promise.all([
|
requests,
|
||||||
fetchFriendRequests(),
|
invites,
|
||||||
fetchRoomInvites()
|
totalCount,
|
||||||
])
|
refreshNotifications
|
||||||
requests.value = fReqs
|
|
||||||
invites.value = rInvs
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch notifications", err)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
requests,
|
|
||||||
invites,
|
|
||||||
totalCount,
|
|
||||||
refreshNotifications
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A reactive object to store the last updated timestamp for each user
|
// A reactive object to store the last updated timestamp for each user
|
||||||
@@ -168,71 +168,71 @@ const avatarTimestamps = reactive<Record<string, number>>({})
|
|||||||
|
|
||||||
|
|
||||||
export function getAvatar(uuid: string): string {
|
export function getAvatar(uuid: string): string {
|
||||||
return `${API}/account/get-avatar/${uuid}`;
|
return `${API}/account/get-avatar/${uuid}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generates the avatar URL with a timestamp
|
// Generates the avatar URL with a timestamp
|
||||||
export function getAvatarUrl(uuid: string | undefined | null) {
|
export function getAvatarUrl(uuid: string | undefined | null) {
|
||||||
if (!uuid) return ''
|
if (!uuid) return ''
|
||||||
|
|
||||||
if (!avatarTimestamps[uuid]) {
|
if (!avatarTimestamps[uuid]) {
|
||||||
avatarTimestamps[uuid] = Date.now()
|
avatarTimestamps[uuid] = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${getAvatar(uuid)}?t=${avatarTimestamps[uuid]}`
|
return `${getAvatar(uuid)}?t=${avatarTimestamps[uuid]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshAvatar(uuid: string) {
|
export function refreshAvatar(uuid: string) {
|
||||||
avatarTimestamps[uuid] = Date.now()
|
avatarTimestamps[uuid] = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Color themes ====
|
// ==== Color themes ====
|
||||||
|
|
||||||
export async function saveThemePreference(themeId: string) {
|
export async function saveThemePreference(themeId: string) {
|
||||||
const s = await getStore();
|
const s = await getStore();
|
||||||
await s.set('theme', themeId);
|
await s.set('theme', themeId);
|
||||||
await s.save();
|
await s.save();
|
||||||
applyTheme(themeId);
|
applyTheme(themeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initTheme() {
|
export async function initTheme() {
|
||||||
const s = await getStore();
|
const s = await getStore();
|
||||||
const themeId = (await s.get<string>('theme')) || 'default';
|
const themeId = (await s.get<string>('theme')) || 'default';
|
||||||
applyTheme(themeId);
|
applyTheme(themeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getThemePreference(): Promise<string> {
|
export async function getThemePreference(): Promise<string> {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
return (await s.get<string>('theme')) ?? 'default'
|
return (await s.get<string>('theme')) ?? 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyStoredTheme() {
|
export async function applyStoredTheme() {
|
||||||
const theme = await getThemePreference();
|
const theme = await getThemePreference();
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Layout ====
|
// ==== Layout ====
|
||||||
|
|
||||||
export async function saveCompactLayoutPreference(enabled: boolean) {
|
export async function saveCompactLayoutPreference(enabled: boolean) {
|
||||||
const s = await getStore();
|
const s = await getStore();
|
||||||
await s.set('compact_layout', enabled);
|
await s.set('compact_layout', enabled);
|
||||||
await s.save();
|
await s.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCompactLayoutPreference(): Promise<boolean> {
|
export async function getCompactLayoutPreference(): Promise<boolean> {
|
||||||
const s = await getStore();
|
const s = await getStore();
|
||||||
return (await s.get<boolean>('compact_layout')) ?? false;
|
return (await s.get<boolean>('compact_layout')) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Message draft ====
|
// ==== Message draft ====
|
||||||
|
|
||||||
export async function saveMessageDraft(text: string) {
|
export async function saveMessageDraft(text: string) {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
await s.set('message_draft', text)
|
await s.set('message_draft', text)
|
||||||
await s.save()
|
await s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMessageDraft(): Promise<string> {
|
export async function getMessageDraft(): Promise<string> {
|
||||||
const s = await getStore()
|
const s = await getStore()
|
||||||
return (await s.get<string>('message_draft')) ?? ''
|
return (await s.get<string>('message_draft')) ?? ''
|
||||||
}
|
}
|
||||||
|
|||||||
196
src/voice.ts
Normal file
196
src/voice.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
import { API_WS, logJS } from './main';
|
||||||
|
import { apiFetch } from './api/client';
|
||||||
|
import WebSocket from '@tauri-apps/plugin-websocket';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
interface VoiceState {
|
||||||
|
status: 'disconnected' | 'connecting' | 'connected';
|
||||||
|
currentRoomUuid: string | null;
|
||||||
|
isMuted: boolean;
|
||||||
|
speakingUsers: Set<string>;
|
||||||
|
captureSampleRate: number;
|
||||||
|
captureChannels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceState = reactive<VoiceState>({
|
||||||
|
status: 'disconnected',
|
||||||
|
currentRoomUuid: null,
|
||||||
|
isMuted: false,
|
||||||
|
speakingUsers: new Set(),
|
||||||
|
captureSampleRate: 48000,
|
||||||
|
captureChannels: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
let unlistenMic: UnlistenFn | null = null;
|
||||||
|
let unlistenConfig: UnlistenFn | null = null;
|
||||||
|
let unlistenError: UnlistenFn | null = null;
|
||||||
|
|
||||||
|
let nextStartTime = 0;
|
||||||
|
|
||||||
|
async function ensureMicrophonePermission() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
// Stop it immediately so it releases the mic
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Microphone permission denied:", err);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup global error listener
|
||||||
|
export async function initVoiceListeners() {
|
||||||
|
if (unlistenError) return;
|
||||||
|
unlistenError = await listen('microphone-error', (event) => {
|
||||||
|
console.error("RUST AUDIO ERROR:", event.payload);
|
||||||
|
voiceActions.leaveRoom();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceActions = {
|
||||||
|
async joinRoom(roomUuid: string) {
|
||||||
|
if (voiceState.status === 'connected') return;
|
||||||
|
|
||||||
|
nextStartTime = 0;
|
||||||
|
voiceState.status = 'connecting';
|
||||||
|
voiceState.currentRoomUuid = roomUuid;
|
||||||
|
|
||||||
|
// Ensure listeners are active
|
||||||
|
await initVoiceListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ token: string }>('/ws/issue-token');
|
||||||
|
const url = `${API_WS}/voice/${roomUuid}?token=${res.token}`;
|
||||||
|
|
||||||
|
socket = await WebSocket.connect(url);
|
||||||
|
|
||||||
|
socket.addListener((msg) => {
|
||||||
|
if (msg.type === 'Binary') {
|
||||||
|
handleIncomingAudio(msg.data);
|
||||||
|
} else if (msg.type === 'Close') {
|
||||||
|
voiceActions.leaveRoom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.startAudioCapture();
|
||||||
|
voiceState.status = 'connected';
|
||||||
|
} catch (e) {
|
||||||
|
logJS(e);
|
||||||
|
console.error("Voice join failed", e);
|
||||||
|
voiceActions.leaveRoom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async leaveRoom() {
|
||||||
|
nextStartTime = 0;
|
||||||
|
voiceState.status = 'disconnected';
|
||||||
|
voiceState.currentRoomUuid = null;
|
||||||
|
voiceState.speakingUsers.clear();
|
||||||
|
|
||||||
|
await this.stopAudioCapture();
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
// WebSocket plugin disconnect is async
|
||||||
|
await socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMute() {
|
||||||
|
voiceState.isMuted = !voiceState.isMuted;
|
||||||
|
},
|
||||||
|
|
||||||
|
async startAudioCapture() {
|
||||||
|
const hasPermission = await ensureMicrophonePermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
throw new Error("Microphone permission denied");
|
||||||
|
}
|
||||||
|
// Give the OS a moment to release the mic resource from the WebView
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
|
||||||
|
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
|
||||||
|
unlistenConfig = await listen<{ sample_rate: number, channels: number }>('microphone-config', (event) => {
|
||||||
|
console.log("Mic Config Received:", event.payload);
|
||||||
|
voiceState.captureSampleRate = event.payload.sample_rate;
|
||||||
|
voiceState.captureChannels = event.payload.channels;
|
||||||
|
});
|
||||||
|
|
||||||
|
unlistenMic = await listen<number[]>('microphone-data', (event) => {
|
||||||
|
if (voiceState.isMuted || !socket) return;
|
||||||
|
|
||||||
|
socket.send(event.payload).catch(console.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('start_microphone');
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to start mic:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopAudioCapture() {
|
||||||
|
try {
|
||||||
|
await invoke('stop_microphone');
|
||||||
|
} catch (e) { console.warn(e); }
|
||||||
|
|
||||||
|
if (unlistenMic) { unlistenMic(); unlistenMic = null; }
|
||||||
|
if (unlistenConfig) { unlistenConfig(); unlistenConfig = null; }
|
||||||
|
|
||||||
|
if (audioContext) {
|
||||||
|
await audioContext.close();
|
||||||
|
audioContext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleIncomingAudio(data: number[]) {
|
||||||
|
if (!audioContext) return;
|
||||||
|
|
||||||
|
const arrayBuffer = new Uint8Array(data).buffer;
|
||||||
|
const audioDataOffset = 16; // Skip UUID
|
||||||
|
|
||||||
|
if (arrayBuffer.byteLength <= audioDataOffset) return;
|
||||||
|
const audioByteLength = arrayBuffer.byteLength - audioDataOffset;
|
||||||
|
|
||||||
|
const pcmData = new Int16Array(arrayBuffer, audioDataOffset, audioByteLength / 2);
|
||||||
|
const float32Data = new Float32Array(pcmData.length);
|
||||||
|
for (let i = 0; i < pcmData.length; i++) {
|
||||||
|
float32Data[i] = pcmData[i] / 32768.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = audioContext.createBuffer(
|
||||||
|
1,
|
||||||
|
float32Data.length,
|
||||||
|
voiceState.captureSampleRate
|
||||||
|
);
|
||||||
|
buffer.getChannelData(0).set(float32Data);
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
|
||||||
|
const now = audioContext.currentTime;
|
||||||
|
|
||||||
|
if (nextStartTime === 0) nextStartTime = now + 0.05;
|
||||||
|
|
||||||
|
// If drifted too far behind (lag > 0.5s), jump ahead
|
||||||
|
if (nextStartTime < now) {
|
||||||
|
nextStartTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStartTime > now + 5.0) {
|
||||||
|
nextStartTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
source.start(nextStartTime);
|
||||||
|
nextStartTime += buffer.duration;
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user