5 Commits
v1.0.1 ... main

33 changed files with 1697 additions and 405 deletions

View File

@@ -1,8 +1,8 @@
{ {
"name": "frangipane-client", "name": "frangipane-client",
"private": true, "private": true,
"version": "1.0.1", "version": "1.0.2",
"backendVersion": "1.0.4", "backendVersion": "1.0.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -16,6 +16,7 @@
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-fs": "~2", "@tauri-apps/plugin-fs": "~2",
"@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-notification": "~2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "~2", "@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-store": "~2", "@tauri-apps/plugin-store": "~2",

249
src-tauri/Cargo.lock generated
View File

@@ -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",
@@ -1027,11 +1101,13 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-os", "tauri-plugin-os",
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-upload", "tauri-plugin-upload",
"tauri-plugin-websocket", "tauri-plugin-websocket",
"tracing",
] ]
[[package]] [[package]]
@@ -1852,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"
@@ -2053,6 +2156,27 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
dependencies = [
"cc",
"objc2",
"objc2-foundation",
"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"
@@ -2202,12 +2326,37 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "notify-rust"
version = "4.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" 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"
@@ -2270,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"
@@ -2281,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"
@@ -2299,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",
] ]
@@ -2767,7 +2966,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.12.1", "indexmap 2.12.1",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@@ -2913,6 +3112,15 @@ dependencies = [
"psl-types", "psl-types",
] ]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -4134,6 +4342,25 @@ dependencies = [
"urlpattern", "urlpattern",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.2",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.2" version = "2.5.2"
@@ -4329,6 +4556,18 @@ dependencies = [
"toml 0.9.8", "toml 0.9.8",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.17",
"windows",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.23.0" version = "3.23.0"
@@ -4657,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",
@@ -4679,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",
] ]

View File

@@ -29,4 +29,7 @@ tauri-plugin-upload = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-os = "2" tauri-plugin-os = "2"
tauri-plugin-notification = "2"
cpal = { version = "0.17.1", features = ["jack"] }
tracing = "0.1.44"

8
src-tauri/Info.plist Normal file
View 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>

View File

@@ -54,6 +54,8 @@
"*" "*"
] ]
}, },
"os:default" "os:default",
"notification:default"
] ]
} }

View File

@@ -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" />

169
src-tauri/src/audio.rs Normal file
View File

@@ -0,0 +1,169 @@
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Host, HostId, 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,
}
/// Helper to resolve host string to cpal::Host
fn get_host_by_name(name: &str) -> Result<Host, String> {
let host_id = cpal::available_hosts()
.into_iter()
.find(|id| id.name() == name)
.ok_or_else(|| format!("Host '{}' not found", name))?;
cpal::host_from_id(host_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_audio_hosts() -> Vec<String> {
cpal::available_hosts()
.into_iter()
.map(|id| id.name().to_string())
.collect()
}
#[tauri::command]
pub fn get_input_devices(host_name: Option<String>) -> Result<Vec<(String, String)>, String> {
// If no host provided, use default
let host = if let Some(name) = host_name {
get_host_by_name(&name)?
} else {
cpal::default_host()
};
let devices = host.input_devices().map_err(|e| e.to_string())?;
let device_names: Vec<(String, String)> = devices
.into_iter()
.filter_map(|device| {
Some((
device.id().ok()?.to_string(),
device.description().ok()?.to_string(),
))
})
.collect();
Ok(device_names)
}
#[tauri::command]
pub fn start_microphone(
app: AppHandle,
state: tauri::State<AudioState>,
host_name: Option<String>,
device_id: Option<String>,
) -> Result<(), String> {
let host = if let Some(ref h_name) = host_name {
get_host_by_name(h_name)?
} else {
cpal::default_host()
};
let device = if let Some(ref id) = device_id {
host.input_devices()
.map_err(|e| e.to_string())?
.find(|d| d.id().map(|i| i.to_string()) == Ok(id.to_string()))
.ok_or_else(|| format!("Device '{}' not found on host '{:?}'", id, host.id()))?
} else {
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))?;
let sample_rate = config.sample_rate();
let device_channels = config.channels() as usize;
let channels = 1;
let buffer_threshold = (sample_rate as usize / 10) * 2;
app.emit(
"microphone-config",
MicConfig {
sample_rate: 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 stream = match sample_format {
cpal::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].clamp(-1.0, 1.0);
let v = if sample >= 0.0 {
(sample * 32767.0) as i16
} else {
(sample * 32768.0) 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,
)
}
cpal::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) {
local_buffer.extend_from_slice(&frame[0].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(())
}

View File

@@ -1,12 +1,16 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ mod audio;
use audio::{get_audio_hosts, get_input_devices, 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_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@@ -15,7 +19,13 @@ 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,
get_input_devices,
get_audio_hosts
])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -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()
} }

View File

@@ -0,0 +1,7 @@
{
"bundle": {
"android": {
"minSdkVersion": 26
}
}
}

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "frangipane", "productName": "frangipane",
"version": "1.0.1", "version": "1.0.2",
"identifier": "com.strawberries.frangipane", "identifier": "com.strawberries.frangipane",
"build": { "build": {
"beforeDevCommand": "yarn dev", "beforeDevCommand": "yarn dev",

View File

@@ -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('')

View File

@@ -1,50 +1,69 @@
import { UpdateUserResponse } from '../types'; import { UpdateUserResponse } from '../types';
import { apiFetch } from './client' import { apiFetch, ApiError } from './client'
// import { upload } from '@tauri-apps/plugin-upload';
import { getAuthData } from '../store'; import { getAuthData } from '../store';
import { API } from '../main.ts'; import { API } from '../main.ts';
export function updateSettings(username: string, email: string, password: string) { export function updateSettings(username: string, email: string, password: string) {
return apiFetch<UpdateUserResponse>('/account/settings', { return apiFetch<UpdateUserResponse>('/account/settings', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ username, email, password }), body: JSON.stringify({ username, email, password }),
}); });
} }
export async function uploadAvatar( export async function uploadAvatar(
fileData: Uint8Array, fileData: Uint8Array,
onProgress: (progress: number, total: number) => void onProgress: (progress: number, total: number) => void
) { ) {
const auth = await getAuthData(); const auth = await getAuthData();
const url = `${API}/account/upload-avatar`; const url = `${API}/account/upload-avatar`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', url); xhr.open('POST', url);
xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`); xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.setRequestHeader('Content-Type', 'application/octet-stream');
// Handle Progress if (xhr.upload && onProgress) {
if (xhr.upload && onProgress) { xhr.upload.onprogress = (event) => {
xhr.upload.onprogress = (event) => { if (event.lengthComputable) {
if (event.lengthComputable) { onProgress(event.loaded, event.total);
onProgress(event.loaded, event.total); }
};
} }
};
}
// Handle Response // Handle Response
xhr.onload = () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response); resolve(xhr.response);
} else { } else {
reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.responseText}`)); if (xhr.status === 413) {
} reject(new ApiError('FILE_TOO_LARGE', 'File too large'));
}; return;
}
xhr.onerror = () => reject(new Error('Network error during upload')); try {
const res = JSON.parse(xhr.responseText);
if (res && res.code && res.message) {
reject(new ApiError(res.code, res.message));
return;
}
} catch (e) {
}
xhr.send(fileData); reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.responseText}`));
}); }
};
xhr.onerror = () => {
if (xhr.status === 413) {
reject(new ApiError('FILE_TOO_LARGE', 'File too large'));
return;
}
reject(new ApiError("UPLOAD_FAILED", "Failed uploading file."));
};
xhr.send(fileData);
});
} }

View File

@@ -3,47 +3,68 @@ import { getAuthData, clearAuthData } from '../store'
import { API } from '../main.ts' import { API } from '../main.ts'
import router from '../router' import router from '../router'
export async function apiFetch<T>( // Custom Error class to hold the backend code
path: string, export class ApiError extends Error {
options: RequestInit = {} code: string;
): Promise<T> {
const auth = await getAuthData()
const isFormData = options.body instanceof FormData; constructor(code: string, message: string) {
super(message);
const res = await fetch(`${API}${path}`, { this.name = 'ApiError';
...options, this.code = code;
method: options.method || 'GET', }
headers: { }
// Only add json header if it's not formdata
...(!isFormData ? { 'Content-Type': 'application/json' } : {}), export async function apiFetch<T>(
...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}), path: string,
...options.headers, options: RequestInit = {}
}, ): Promise<T> {
}) const auth = await getAuthData()
if (res.status === 401 && auth.token) { const isFormData = options.body instanceof FormData;
await clearAuthData()
router.push('/login') const res = await fetch(`${API}${path}`, {
throw new Error("Session expired") ...options,
} method: options.method || 'GET',
headers: {
// Handle error responses ...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
if (!res.ok) { ...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
const text = await res.text() ...options.headers,
throw new Error(text || res.statusText) },
} })
// Get the response as text first // Invalid token?
const responseText = await res.text() if (res.status === 401) {
if (auth.token) {
if (!responseText) { await clearAuthData()
return {} as T router.push('/login')
} }
}
try {
return JSON.parse(responseText) as T // Handle error responses
} catch (e) { if (!res.ok) {
return responseText as unknown as T const text = await res.text()
}
try {
const json = JSON.parse(text);
if (json && json.code && json.message) {
throw new ApiError(json.code, json.message);
}
} catch (e) {
if (e instanceof ApiError) throw e;
}
throw new Error(text || res.statusText)
}
const responseText = await res.text()
if (!responseText) {
return {} as T
}
try {
return JSON.parse(responseText) as T
} catch (e) {
return responseText as unknown as T
}
} }

View File

@@ -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;
} }

View File

@@ -3,8 +3,11 @@
@close="showInviteModal = false" @room-changed="handleRoomChanged" /> @close="showInviteModal = false" @room-changed="handleRoomChanged" />
<RoomDetailsModal v-if="showDetailsModal && isSocketConnected" :roomUuid="props.uuid" <RoomDetailsModal v-if="showDetailsModal && isSocketConnected" :roomUuid="props.uuid"
:roomName="currentRoom?.name || 'Unknown room'" :isGlobal="currentRoom?.global || false" :ownerUuid="currentRoom?.owner_uuid || ''" :roomName="currentRoom?.name || 'Unknown room'" :isGlobal="currentRoom?.global || false"
@close="showDetailsModal = false" @room-changed="handleRoomChanged" /> :ownerUuid="currentRoom?.owner_uuid || ''" @close="showDetailsModal = false"
@room-changed="handleRoomChanged" />
<VoiceDeviceModal v-if="showVoiceModal" @close="showVoiceModal = false" @select="handleVoiceSelect" />
<div v-if="uuid === 'none'" class="no-room"> <div v-if="uuid === 'none'" class="no-room">
<div class="empty-state"> <div class="empty-state">
@@ -43,6 +46,10 @@
<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 }">
<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>
@@ -62,10 +69,13 @@ import MessageList from "./MessageList.vue";
import MessageInput from "./MessageInput.vue"; import MessageInput from "./MessageInput.vue";
import InvitePeopleModal from './InvitePeopleModal.vue'; import InvitePeopleModal from './InvitePeopleModal.vue';
import RoomDetailsModal from "./RoomDetailsModal.vue"; import RoomDetailsModal from "./RoomDetailsModal.vue";
import VoiceDeviceModal from "./VoiceDeviceModal.vue";
import WebSocket from '@tauri-apps/plugin-websocket'; import WebSocket from '@tauri-apps/plugin-websocket';
import { getAuthData } from "../store.ts"; 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 { voiceActions, voiceState } from "../voice";
const { $t } = useFluent(); const { $t } = useFluent();
@@ -88,9 +98,11 @@ const connectionError = ref<string | null>(null);
// Pagination State // Pagination State
const isLoadingMore = ref(false); const isLoadingMore = ref(false);
const hasMore = ref(true); const hasMore = ref(true);
const isInitialLoad = ref(false);
const showInviteModal = ref(false); const showInviteModal = ref(false);
const showDetailsModal = ref(false); const showDetailsModal = ref(false);
const isInitialLoad = ref(false); const showVoiceModal = ref(false);
// WebSocket State // WebSocket State
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
@@ -107,6 +119,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();
@@ -170,7 +186,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}`;
@@ -194,6 +210,12 @@ async function connectGlobalWebSocket() {
} else { } else {
// Notifications for other rooms // Notifications for other rooms
emit('notification', data.room_uuid); emit('notification', data.room_uuid);
sendNotification({
title: $t('notifications-message-title', { messageType: data.message_type, senderUsername: data.sender }),
body: data.content,
// channelId: 'messages',
});
} }
} catch (e) { } catch (e) {
@@ -301,6 +323,26 @@ 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 {
showVoiceModal.value = true;
}
}
async function handleVoiceSelect(selection: { host: string | null, device: string | null }) {
showVoiceModal.value = false;
if (!selection) return;
try {
await voiceActions.joinRoom(props.uuid, selection.host, selection.device);
} catch (e) {
console.error("Failed to join voice", e);
}
}
</script> </script>
<style scoped> <style scoped>
@@ -327,7 +369,7 @@ async function onSend(content: string) {
color: var(--muted); color: var(--muted);
font-size: 1.1rem; font-size: 1.1rem;
z-index: 10; z-index: 10;
pointer-events: none; /* pointer-events: none; */
text-align: center; text-align: center;
} }
@@ -416,7 +458,7 @@ async function onSend(content: string) {
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
z-index: 10; z-index: 10;
pointer-events: none; /* pointer-events: none; */
text-align: center; text-align: center;
width: 100%; width: 100%;
} }
@@ -449,4 +491,8 @@ async function onSend(content: string) {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
} }
.active-voice {
color: #f87171;
}
</style> </style>

View File

@@ -1,95 +1,106 @@
<template> <template>
<div class="backdrop" @click.self="emit('close')"> <div class="backdrop" @click.self="emit('close')">
<form class="modal" @submit.prevent="submit"> <form class="modal" @submit.prevent="submit">
<h2>{{ $t('chat-create-title') }}</h2> <h2>{{ $t('chat-create-title') }}</h2>
<div class="input-group"> <p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
<label>{{ $t('chat-create-name') }}</label>
<input v-model="name" :placeholder="$t('chat-create-name-placeholder')" autofocus />
</div>
<!-- <div class="input-group">
<label>{{ $t('chat-create-name') }}</label>
<input v-model="name" :placeholder="$t('chat-create-name-placeholder')" autofocus />
</div>
<!--
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" v-model="global" /> <input type="checkbox" v-model="global" />
<span>{{ $t('chat-create-global') }}</span> <span>{{ $t('chat-create-global') }}</span>
</label> </label>
--> -->
<div class="actions"> <div class="actions">
<button type="button" @click="emit('close')" class="secondary"> <button type="button" @click="emit('close')" class="secondary">
{{ $t('shared-cancel') }} {{ $t('shared-cancel') }}
</button> </button>
<button type="submit"> <button type="submit">
{{ $t('chat-create-submit') }} {{ $t('chat-create-submit') }}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { createRoom } from '../api/rooms' import { createRoom } from '../api/rooms'
import type { Room } from '../types' import type { Room } from '../types'
import { useErrorTranslator } from '../errors';
const { translateError } = useErrorTranslator();
const errorMessage = ref('')
const name = ref('') const name = ref('')
const global = ref(false) const global = ref(false)
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'created', room: Room): void (e: 'created', room: Room): void
(e: 'close'): void (e: 'close'): void
}>() }>()
async function submit() { async function submit() {
const room = await createRoom(name.value, global.value) try {
emit('created', room) const room = await createRoom(name.value, global.value)
emit('close') emit('created', room)
name.value = '' emit('close')
global.value = false name.value = ''
global.value = false
}
catch (err) {
errorMessage.value = translateError(err);
}
} }
</script> </script>
<style scoped> <style scoped>
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
} }
.modal { .modal {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 1.5rem; padding: 1.5rem;
width: 100%; width: 100%;
max-width: 420px; max-width: 420px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.modal h2 { .modal h2 {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
} }
.checkbox { .checkbox {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: var(--muted); color: var(--muted);
font-size: 0.9rem; font-size: 0.9rem;
} }
.actions { .actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
} }
</style> </style>

View File

@@ -33,8 +33,10 @@ import { ref } from 'vue'
import { sendRoomInvite } from '../api/rooms' import { sendRoomInvite } from '../api/rooms'
import { sendFriendRequest } from '../api/friends'; import { sendFriendRequest } from '../api/friends';
import { useFluent } from 'fluent-vue'; import { useFluent } from 'fluent-vue';
import { useErrorTranslator } from '../errors';
const { $t } = useFluent(); const { $t } = useFluent();
const { translateError } = useErrorTranslator();
const props = defineProps<{ room_uuid: string }>(); const props = defineProps<{ room_uuid: string }>();
@@ -50,10 +52,7 @@ async function submit() {
errorMessage.value = '' errorMessage.value = ''
const username = receiverUsername.value.trim() const username = receiverUsername.value.trim()
if (!username) { if (!username) return
// errorMessage.value = 'Username is required.'
return
}
try { try {
await sendRoomInvite(username, props.room_uuid) await sendRoomInvite(username, props.room_uuid)
@@ -67,7 +66,7 @@ async function submit() {
emit('close') emit('close')
} catch (err: any) { } catch (err: any) {
errorMessage.value = err?.message || err || $t('shared-error'); errorMessage.value = translateError(err);
} }
} }
</script> </script>

View File

@@ -41,8 +41,10 @@ import { updateSettings } from '../api/account'
import { updateLocalUser } from '../store' import { updateLocalUser } from '../store'
import type { User } from '../types' import type { User } from '../types'
import { useFluent } from 'fluent-vue'; import { useFluent } from 'fluent-vue';
import { useErrorTranslator } from '../errors';
const { $t } = useFluent(); const { $t } = useFluent();
const { translateError } = useErrorTranslator();
const props = defineProps<{ user: User | null }>() const props = defineProps<{ user: User | null }>()
const emit = defineEmits(['close', 'updated']) const emit = defineEmits(['close', 'updated'])
@@ -84,7 +86,7 @@ async function submit() {
emit('updated') emit('updated')
emit('close') emit('close')
} catch (err: any) { } catch (err: any) {
errorMessage.value = err?.message || $t('settings-error-failed') errorMessage.value = translateError(err);
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
} }

View File

@@ -47,8 +47,10 @@ import { refreshLocalUser } from '../store.ts';
import { getAuthData } from '../store.ts'; import { getAuthData } from '../store.ts';
import { refreshAvatar } from '../store.ts'; import { refreshAvatar } from '../store.ts';
import { useFluent } from 'fluent-vue'; import { useFluent } from 'fluent-vue';
import { useErrorTranslator } from '../errors.ts';
const { $t } = useFluent(); const { $t } = useFluent();
const { translateError } = useErrorTranslator();
const emit = defineEmits(['close', 'updated']); const emit = defineEmits(['close', 'updated']);
@@ -166,7 +168,8 @@ async function handleUpload() {
emit('close'); emit('close');
} catch (err: any) { } catch (err: any) {
console.error("Upload failed:", err); console.error("Upload failed:", err);
errorMessage.value = $t('settings-error-upload-avatar-failed-upload'); const msg = translateError(err);
errorMessage.value = msg !== 'An error occurred' ? msg : $t('settings-error-upload-avatar-failed-upload');
isSubmitting.value = false; isSubmitting.value = false;
} }
} }

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

View File

@@ -0,0 +1,198 @@
<template>
<div class="backdrop" @click.self="$emit('close')">
<div class="modal">
<div v-if="step === 1" class="step-container">
<h3>{{ $t('chat-voice-select-host') || 'Select Audio System' }}</h3>
<div v-if="loadingHosts" class="loading">Loading systems...</div>
<div v-else class="list">
<button @click="selectHost(null)" class="item default">
<i class="fa-solid fa-wand-magic-sparkles"></i>
<span>Automatic (Default)</span>
</button>
<button v-for="host in hosts" :key="host" @click="selectHost(host)" class="item">
<i class="fa-solid fa-server"></i>
<span>{{ host }}</span>
</button>
</div>
</div>
<div v-else class="step-container">
<div class="header">
<button class="back-btn" @click="step = 1">
<i class="fa-solid fa-arrow-left"></i>
</button>
<h3>{{ selectedHost }} Devices</h3>
</div>
<div v-if="loadingDevices" class="loading">Scanning devices...</div>
<div v-else class="list">
<button @click="selectDevice(null)" class="item default">
<i class="fa-solid fa-star"></i>
<span>Default Device</span>
</button>
<button v-for="device in devices" :key="device[0]" @click="selectDevice(device[0])" class="item">
<i class="fa-solid fa-microphone"></i>
<div class="device-details">
<span class="device-name">{{ device[1] }}</span>
</div>
</button>
</div>
</div>
<div class="actions">
<button class="secondary" @click="$emit('close')">Cancel</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { voiceActions } from '../voice';
const emit = defineEmits(['close', 'select']);
const step = ref(1);
const hosts = ref<string[]>([]);
const devices = ref<string[]>([]); // Array of [id, name] tuples
const selectedHost = ref<string | null>(null);
const loadingHosts = ref(false);
const loadingDevices = ref(false);
onMounted(async () => {
loadingHosts.value = true;
hosts.value = await voiceActions.getHosts();
loadingHosts.value = false;
});
async function selectHost(host: string | null) {
selectedHost.value = host;
// Default
if (host === null) {
emit('select', { host: null, device: null });
return;
}
step.value = 2;
loadingDevices.value = true;
devices.value = await voiceActions.getDevices(host);
loadingDevices.value = false;
}
function selectDevice(deviceId: string | null) {
emit('select', { host: selectedHost.value, device: deviceId });
}
</script>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
width: 100%;
max-width: 500px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.step-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
align-items: center;
gap: 1rem;
}
.back-btn {
background: transparent;
border: none;
color: var(--text);
cursor: pointer;
font-size: 1.1rem;
padding: 0.5rem;
border-radius: 50%;
}
.back-btn:hover {
background: var(--panel-hover);
}
.list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 400px;
overflow-y: auto;
padding-right: 5px;
}
.item {
background: none;
text-align: left;
font-weight: 500;
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
align-items: center;
padding: 0.8rem;
gap: 0.8rem;
transition: all 0.2s ease;
min-width: 0;
}
.item:hover {
border-color: var(--accent-color);
background: var(--panel-hover);
}
.device-details {
display: flex;
flex-direction: column;
overflow: hidden;
}
.device-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.loading {
text-align: center;
color: var(--muted);
padding: 2rem;
}
</style>

30
src/errors.ts Normal file
View File

@@ -0,0 +1,30 @@
import { useFluent } from 'fluent-vue';
import { ApiError } from './api/client';
export function useErrorTranslator() {
const { $t } = useFluent();
function translateError(err: unknown): string {
if (err instanceof ApiError) {
// Convert "AUTH_INVALID_CREDENTIALS" -> "error-auth-invalid-credentials"
const key = `error-${err.code.toLowerCase().replace(/_/g, '-')}`;
const translated = $t(key);
// Fallback to the message provided by backend.
if (translated === key) {
return err.message;
}
return translated;
}
if (err instanceof Error) {
return err.message;
}
return $t('shared-error');
}
return { translateError };
}

View File

@@ -18,7 +18,6 @@ auth-login-btn = Login
auth-register-btn = Create Account auth-register-btn = Create Account
auth-no-account = Don't have an account? auth-no-account = Don't have an account?
auth-has-account = Already have an account? auth-has-account = Already have an account?
auth-error-unknown = An unknown error occurred
auth-error-password-match = Passwords do not match auth-error-password-match = Passwords do not match
auth-error-password-length = Password must be at least 8 characters long auth-error-password-length = Password must be at least 8 characters long
auth-error-email-invalid = Please enter a valid email address auth-error-email-invalid = Please enter a valid email address
@@ -51,6 +50,7 @@ chat-create-global = Global room
chat-create-submit = Create chat-create-submit = Create
chat-connecting = Connecting to room... chat-connecting = Connecting to room...
chat-connecting-failed = Could not connect. Check internet connection. chat-connecting-failed = Could not connect. Check internet connection.
chat-voice-select-input = Select input device.
## User profile ## User profile
profile-title = User profile profile-title = User profile
@@ -67,6 +67,8 @@ friends-add-title = Add Friend
friends-send-request = Send Request friends-send-request = Send Request
friends-list-header = Your Friends friends-list-header = Your Friends
friends-error-required = Username is required. friends-error-required = Username is required.
friends-connectiong = Loading friends...
friends-list-empty = No friends found
## Notifications page ## Notifications page
notifications-title = Notifications notifications-title = Notifications
@@ -82,6 +84,8 @@ notifications-error-friend-accept = An error occurred while accepting the reques
notifications-error-friend-decline = An error occurred while declining the request. notifications-error-friend-decline = An error occurred while declining the request.
notifications-error-room-accept = An error occurred while accepting the invite. notifications-error-room-accept = An error occurred while accepting the invite.
notifications-error-room-decline = An error occurred while declining the invite. notifications-error-room-decline = An error occurred while declining the invite.
notifications-connectiong = Loading notifications...
notifications-empty = No notifications found
## Settings page ## Settings page
settings-loading = Loading settings... settings-loading = Loading settings...
@@ -123,3 +127,37 @@ shared-updating = Updating
shared-delete = Delete shared-delete = Delete
shared-leave = Leave shared-leave = Leave
shared-confirm = Confirm shared-confirm = Confirm
## Notifications
notifications-message-title = New {$messageType} message from {$senderUsername}
## Errors (backend)
error-auth-invalid-credentials = Invalid email or password.
error-auth-missing-token = Authentication missing.
error-auth-invalid-token = Session expired or invalid.
error-user-not-found = User not found.
error-user-email-taken = Email is already in use.
error-user-username-taken = Username is already taken.
error-user-username-length = Username must be 1-35 characters long.
error-user-invalid-email = Invalid email format.
error-user-password-too-short = Password must be at least 8 characters.
error-user-empty-fields = Required fields are empty.
error-avatar-not-found = Avatar not found.
error-room-not-found = Room not found.
error-room-name-length = Room name must be 1-35 characters long.
error-room-not-member = You are not a member of this room.
error-room-already-member = This person is already a member.
error-room-owner-cannot-leave = Owner cannot leave the room without transferring ownership.
error-room-global-no-members = Cannot list members for global rooms.
error-invite-self = You cannot invite yourself.
error-invite-already-sent = Invite already sent.
error-invite-not-found = Invite not found.
error-friend-request-self = You cannot friend request yourself.
error-friend-already-exists = You are already friends.
error-friend-request-already-sent = Friend request already pending.
error-friend-request-not-found = Friend request not found.
error-friend-not-found = User is not in your friends list.
error-internal-server-error = An error occured.
error-internal-db-error = An error occured.
error-file-too-large = The file is too large (max 2MB).
error-upload-failed = Failed to upload the file.

View File

@@ -18,7 +18,6 @@ auth-login-btn = Se connecter
auth-register-btn = Créer un compte auth-register-btn = Créer un compte
auth-no-account = Pas encore de compte ? auth-no-account = Pas encore de compte ?
auth-has-account = Déjà un compte ? auth-has-account = Déjà un compte ?
auth-error-unknown = Une erreur inconnue est survenue
auth-error-password-match = Les mots de passe ne correspondent pas auth-error-password-match = Les mots de passe ne correspondent pas
auth-error-password-length = Le mot de passe doit faire au moins 8 caractères auth-error-password-length = Le mot de passe doit faire au moins 8 caractères
auth-error-email-invalid = Veuillez entrer une adresse email valide auth-error-email-invalid = Veuillez entrer une adresse email valide
@@ -51,6 +50,7 @@ chat-create-global = Salon public
chat-create-submit = Créer chat-create-submit = Créer
chat-connecting = Connexion au salon... chat-connecting = Connexion au salon...
chat-connecting-failed = Impossible d'établir la connexion. Vérifiez votre internet. chat-connecting-failed = Impossible d'établir la connexion. Vérifiez votre internet.
chat-voice-select-input = Sélectionnez un périphérique d'entrée.
## User profile ## User profile
profile-title = Profil d'utilisateur profile-title = Profil d'utilisateur
@@ -67,6 +67,8 @@ friends-add-title = Ajouter un ami
friends-send-request = Envoyer friends-send-request = Envoyer
friends-list-header = Vos Amis friends-list-header = Vos Amis
friends-error-required = Le nom d'utilisateur est requis. friends-error-required = Le nom d'utilisateur est requis.
friends-connectiong = Chargement des amis...
friends-list-empty = Pas d'amis trouvés
## Notifications page ## Notifications page
notifications-title = Notifications notifications-title = Notifications
@@ -82,6 +84,8 @@ notifications-error-friend-accept = Erreur lors de l'acceptation de la demande.
notifications-error-friend-decline = Erreur lors du refus de la demande. notifications-error-friend-decline = Erreur lors du refus de la demande.
notifications-error-room-accept = Erreur lors de l'acceptation de l'invitation. notifications-error-room-accept = Erreur lors de l'acceptation de l'invitation.
notifications-error-room-decline = Erreur lors du refus de l'invitation. notifications-error-room-decline = Erreur lors du refus de l'invitation.
notifications-connectiong = Chargement des notifications...
notifications-empty = Pas de notifications trouvées
## Settings page ## Settings page
settings-loading = Chargement des paramètres... settings-loading = Chargement des paramètres...
@@ -121,3 +125,37 @@ shared-updating = Mise à jour...
shared-delete = Supprimer shared-delete = Supprimer
shared-leave = Quitter shared-leave = Quitter
shared-confirm = Confirmer shared-confirm = Confirmer
## Notifications
notifications-message-title = Nouveau message {$messageType} de {$senderUsername}
## Errors (backend)
error-auth-invalid-credentials = Email ou mot de passe incorrect.
error-auth-missing-token = Authentification manquante.
error-auth-invalid-token = Session expirée ou invalide.
error-user-not-found = Utilisateur introuvable.
error-user-email-taken = L'adresse email est déjà utilisée.
error-user-username-taken = Ce nom d'utilisateur est déjà pris.
error-user-username-length = Le nom d'utilisateur doit faire 1-35 caractères.
error-user-invalid-email = Format d'email invalide.
error-user-password-too-short = Le mot de passe doit faire au moins 8 caractères.
error-user-empty-fields = Des champs requis sont vides.
error-avatar-not-found = Avatar introuvable.
error-room-not-found = Salon introuvable.
error-room-name-length = Le nom de la salle doit faire 1-35 caractères.
error-room-not-member = Vous n'êtes pas membre de ce salon.
error-room-already-member = Cette personne est déjà membre.
error-room-owner-cannot-leave = Le propriétaire ne peut pas quitter le salon sans transférer la propriété.
error-room-global-no-members = Impossible de lister les membres d'un salon global.
error-invite-self = Vous ne pouvez pas vous inviter vous-même.
error-invite-already-sent = Invitation déjà envoyée.
error-invite-not-found = Invitation introuvable.
error-friend-request-self = Vous ne pouvez pas vous ajouter en ami.
error-friend-already-exists = Vous êtes déjà amis.
error-friend-request-already-sent = Demande d'ami déjà en attente.
error-friend-request-not-found = Demande d'ami introuvable.
error-friend-not-found = L'utilisateur n'est pas dans votre liste d'amis.
error-internal-server-error = Une erreur est survenue.
error-internal-db-error = Une erreur est survenue.
error-file-too-large = Le fichier est trop volumineux (max 2Mo).
error-upload-failed = Erreur lors de l'envoi du fichier.

View File

@@ -3,9 +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 './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()
@@ -25,14 +29,34 @@ async function init() {
setLanguage(osLocale); setLanguage(osLocale);
} }
// await createChannel({
// id: 'messages',
// name: 'Messages',
// description: 'Notifications for new messages',
// importance: Importance.High,
// visibility: Visibility.Private,
// lights: true,
// lightColor: '#ff0000',
// vibration: true,
// 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'

View File

@@ -1,31 +1,46 @@
<template> <template>
<div class="friends-page"> <div class="friends-page">
<header class="friends-header"> <header class="friends-header">
<h1>{{ $t('friends-title') }}</h1> <h1>{{ $t('friends-title') }}</h1>
<form class="friend-request-form" @submit.prevent="send"> <form class="friend-request-form" @submit.prevent="send">
<h3>{{ $t('friends-add-title') }}</h3> <h3>{{ $t('friends-add-title') }}</h3>
<div class="input-container"> <div class="input-container">
<input v-model="username" :placeholder="$t('auth-username')" autofocus /> <input v-model="username" :placeholder="$t('auth-username')" autofocus />
<button type="submit">{{ $t('friends-send-request') }}</button> <button type="submit">{{ $t('friends-send-request') }}</button>
</div>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
</form>
</header>
<div class="friends-list">
<h2>{{ $t('friends-list-header') }}</h2>
<!-- Loading State -->
<div class="wait-container" v-if="isLoading">
<p class="wait-msg">{{ $t('friends-connecting') }}</p>
</div>
<!-- Empty / Error / Retry State -->
<div class="wait-container" v-else-if="!friends || friends.length === 0">
<p class="wait-msg">{{ $t('friends-list-empty') || 'No friends yet' }}</p>
<button class="retry-btn" @click="loadFriends()">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<!-- Content State -->
<div class="friends" v-else>
<button class="friend" v-for="friend in friends" :key="friend.uuid" @click="openProfile(friend)">
<img :src="getAvatarUrl(friend.uuid)" @error="handleAvatarError" class="avatar" />
<p>{{ friend.username }}</p>
</button>
</div>
</div> </div>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
</form>
</header>
<div class="friends-list"> <UserProfileModal v-if="selectedFriend" :username="selectedFriend.username" :user-uuid="selectedFriend.uuid"
<h2>{{ $t('friends-list-header') }}</h2> @close="selectedFriend = null" />
<div class="friends">
<button class="friend" v-for="friend in friends" :key="friend.uuid" @click="openProfile(friend)">
<img :src="getAvatarUrl(friend.uuid)" @error="handleAvatarError" class="avatar" />
<p>{{ friend.username }}</p>
</button>
</div>
</div> </div>
<UserProfileModal v-if="selectedFriend" :username="selectedFriend.username" :user-uuid="selectedFriend.uuid"
@close="selectedFriend = null" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -35,126 +50,163 @@ import type { Friend } from '../types'
import { getAvatarUrl } from '../store' import { getAvatarUrl } from '../store'
import defaultAvatar from '../assets/default-avatar.png' import defaultAvatar from '../assets/default-avatar.png'
import UserProfileModal from '../components/UserProfileModal.vue' import UserProfileModal from '../components/UserProfileModal.vue'
import { useErrorTranslator } from '../errors'
const friends = ref<Friend[]>([]) const friends = ref<Friend[]>([])
const username = ref('') const username = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const isLoading = ref(true)
const { translateError } = useErrorTranslator();
const selectedFriend = ref<Friend | null>(null) const selectedFriend = ref<Friend | null>(null)
async function loadFriends() {
isLoading.value = true
try {
friends.value = await fetchFriends()
} catch (err) {
console.error("Failed to fetch friends", err)
} finally {
isLoading.value = false
}
}
onMounted(async () => { onMounted(async () => {
friends.value = await fetchFriends() await loadFriends()
}) })
const openProfile = (friend: Friend) => { const openProfile = (friend: Friend) => {
selectedFriend.value = friend selectedFriend.value = friend
} }
const handleAvatarError = (event: Event) => { const handleAvatarError = (event: Event) => {
const img = event.target as HTMLImageElement; const img = event.target as HTMLImageElement;
img.src = defaultAvatar; img.src = defaultAvatar;
}; };
async function send() { async function send() {
if (!username.value) { if (!username.value) return
// errorMessage.value = 'Username is required.'
return
}
try { try {
await sendFriendRequest(username.value) await sendFriendRequest(username.value)
username.value = '' username.value = ''
errorMessage.value = '' errorMessage.value = ''
friends.value = await fetchFriends() await loadFriends()
} catch (err: any) { } catch (err: any) {
errorMessage.value = err errorMessage.value = translateError(err);
} }
} }
</script> </script>
<style scoped> <style scoped>
.friends-page { .friends-page {
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
} }
.friends-header { .friends-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.friend-request-form { .friend-request-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.input-container { .input-container {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
.friend-request-form input { .friend-request-form input {
padding: 0.5rem; padding: 0.5rem;
border-radius: var(--radius); border-radius: var(--radius);
border: 1px solid var(--border); border: 1px solid var(--border);
flex-grow: 1; flex-grow: 1;
} }
.friend-request-form button { .friend-request-form button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: var(--radius); border-radius: var(--radius);
} }
.error-message { .error-message {
color: var(--error); color: var(--error);
font-size: 0.9rem; font-size: 0.9rem;
} }
.friends-list { .friends-list {
min-width: 250px; position: relative;
background: var(--panel); min-width: 250px;
border: 1px solid var(--border); background: var(--panel);
border-radius: var(--radius); border: 1px solid var(--border);
padding: 1rem; border-radius: var(--radius);
padding: 1rem;
}
.wait-container {
display: flex;
flex-direction: column;
gap: 1.2rem;
align-items: center;
justify-content: center;
padding: 2rem 0;
width: 100%;
}
.wait-msg {
color: var(--muted);
font-size: 1.1rem;
text-align: center;
}
.retry-btn {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
padding: 0.5rem 1rem;
border-radius: var(--radius);
cursor: pointer;
}
.retry-btn:hover {
background: var(--panel-hover);
} }
.friend { .friend {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
background: var(--panel-light); background: var(--panel-light);
color: var(--text); color: var(--text);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
/* padding: 0.75rem 1rem; */ margin-bottom: 0.5rem;
margin-bottom: 0.5rem; display: flex;
display: flex; flex-direction: row;
flex-direction: row; align-items: center;
align-items: center; gap: 1.2rem;
gap: 1.2rem; cursor: pointer;
margin: 0 0 0.5rem 0;
} }
.friend:hover p { .friend:hover p {
text-decoration: underline; text-decoration: underline;
} }
/* .friend:hover { */
/* background-color: var(--panel-hover); */
/* } */
.friends-list h2 { .friends-list h2 {
font-size: 1.25rem; font-size: 1.25rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.avatar { .avatar {
height: 42px; height: 42px;
width: 42px; width: 42px;
} }
</style> </style>

View File

@@ -24,6 +24,7 @@ import { ref } from "vue";
import { login } from '../store.ts' import { login } from '../store.ts'
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useFluent } from 'fluent-vue'; import { useFluent } from 'fluent-vue';
import { useErrorTranslator } from "../errors.ts";
const email = ref(""); const email = ref("");
const password = ref(""); const password = ref("");
@@ -32,6 +33,7 @@ const errorMessage = ref("");
const router = useRouter(); const router = useRouter();
const { $t } = useFluent(); const { $t } = useFluent();
const { translateError } = useErrorTranslator();
async function submit() { async function submit() {
errorMessage.value = ""; errorMessage.value = "";
@@ -39,7 +41,8 @@ async function submit() {
await login(email.value, "", password.value); await login(email.value, "", password.value);
router.push("/"); router.push("/");
} catch (err: any) { } catch (err: any) {
errorMessage.value = err?.message || $t('auth-error-unknown'); errorMessage.value = translateError(err);
// errorMessage.value = err?.message || $t('auth-error-unknown');
} }
} }
</script> </script>

View File

@@ -47,8 +47,10 @@ import { fetchFriendRequests, acceptFriendRequest, declineFriendRequest } from '
import { fetchRoomInvites, acceptRoomInvite, declineRoomInvite } from '../api/rooms.ts' import { fetchRoomInvites, acceptRoomInvite, declineRoomInvite } from '../api/rooms.ts'
import { useNotifications } from '../store' import { useNotifications } from '../store'
import { useFluent } from 'fluent-vue'; import { useFluent } from 'fluent-vue';
import { useErrorTranslator } from '../errors.ts';
const { $t } = useFluent(); const { $t } = useFluent();
const { translateError } = useErrorTranslator();
const errorMessage = ref('') const errorMessage = ref('')
const { requests, invites, refreshNotifications } = useNotifications() const { requests, invites, refreshNotifications } = useNotifications()
@@ -65,7 +67,8 @@ async function acceptFriend(senderUuid: string) {
requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid) requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid)
// fetchFriends().then(f => (friends.value = f)) // fetchFriends().then(f => (friends.value = f))
} catch (err) { } catch (err) {
errorMessage.value = $t('notifications-error-friend-accept') const msg = translateError(err);
errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-friend-accept')
} }
} }
@@ -75,7 +78,8 @@ async function declineFriend(senderUuid: string) {
requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid) requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid)
// fetchFriends().then(f => (friends.value = f)) // fetchFriends().then(f => (friends.value = f))
} catch (err) { } catch (err) {
errorMessage.value = $t('notifications-error-friend-decline') const msg = translateError(err);
errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-friend-decline')
} }
} }
@@ -84,8 +88,8 @@ async function acceptRoom(senderUuid: string, roomUuid: string) {
await acceptRoomInvite(senderUuid, roomUuid) await acceptRoomInvite(senderUuid, roomUuid)
invites.value = invites.value.filter(r => r.room_uuid !== roomUuid) invites.value = invites.value.filter(r => r.room_uuid !== roomUuid)
} catch (err) { } catch (err) {
errorMessage.value = $t('notifications-error-room-accept') const msg = translateError(err);
throw err errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-room-accept')
} }
} }
@@ -94,8 +98,8 @@ async function declineRoom(senderUuid: string, roomUuid: string) {
await declineRoomInvite(senderUuid, roomUuid) await declineRoomInvite(senderUuid, roomUuid)
invites.value = invites.value.filter(r => r.room_uuid !== roomUuid) invites.value = invites.value.filter(r => r.room_uuid !== roomUuid)
} catch (err) { } catch (err) {
errorMessage.value = $t('notifications-error-room-decline') const msg = translateError(err);
throw err errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-room-decline')
} }
} }
</script> </script>

View File

@@ -22,7 +22,7 @@
<button type="submit">{{ $t('auth-register-btn') }}</button> <button type="submit">{{ $t('auth-register-btn') }}</button>
<p class="login-link"> <p class="login-link">
{{ $t('auth-has-settings') }} <router-link to="/login">{{ $t('auth-login-title') }}</router-link> {{ $t('auth-has-account') }} <router-link to="/login">{{ $t('auth-login-title') }}</router-link>
</p> </p>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p> <p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
@@ -35,8 +35,10 @@ import { ref } from "vue";
import { register } from '../store.ts' import { register } from '../store.ts'
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useFluent } from 'fluent-vue'; import { useFluent } from 'fluent-vue';
import { useErrorTranslator } from '../errors';
const { $t } = useFluent(); const { $t } = useFluent();
const { translateError } = useErrorTranslator();
const email = ref(""); const email = ref("");
const username = ref(""); const username = ref("");
@@ -46,22 +48,18 @@ const errorMessage = ref("");
const router = useRouter(); const router = useRouter();
async function submit(event: Event) { async function submit() {
errorMessage.value = ""; errorMessage.value = "";
const form = event.target as HTMLFormElement; // if (!form.checkValidity()) {
// if (password.value.length < 8) {
// errorMessage.value = $t('auth-error-password-length');
// } else {
// errorMessage.value = $t('auth-error-email-invalid');
// }
// return;
// }
// Check password length and email
if (!form.checkValidity()) {
if (password.value.length < 8) {
errorMessage.value = $t('auth-error-password-length');
} else {
errorMessage.value = $t('auth-error-email-invalid');
}
return;
}
// Check password match
if (password.value !== confirmPassword.value) { if (password.value !== confirmPassword.value) {
errorMessage.value = $t('auth-error-password-match'); errorMessage.value = $t('auth-error-password-match');
return; return;
@@ -71,7 +69,7 @@ async function submit(event: Event) {
await register(email.value, username.value, password.value); await register(email.value, username.value, password.value);
router.push("/"); router.push("/");
} catch (err: any) { } catch (err: any) {
errorMessage.value = err?.message || "An unknown error occurred"; errorMessage.value = translateError(err);
} }
} }
</script> </script>

View File

@@ -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')) ?? ''
} }

199
src/voice.ts Normal file
View File

@@ -0,0 +1,199 @@
import { reactive } from 'vue';
import { API_WS } 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 getHosts(): Promise<string[]> {
try {
return await invoke<string[]>('get_audio_hosts');
} catch (e) {
console.error(e);
return [];
}
},
async getDevices(hostName: string | null): Promise<string[]> {
try {
return await invoke('get_input_devices', { hostName });
} catch (e) {
console.error(e);
return [];
}
},
async joinRoom(roomUuid: string, hostName: string | null, deviceName: string | null) {
if (voiceState.status === 'connected') return;
nextStartTime = 0;
voiceState.status = 'connecting';
voiceState.currentRoomUuid = roomUuid;
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(hostName, deviceName);
voiceState.status = 'connected';
} catch (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(hostName: string | null, deviceName: string | null) {
const hasPermission = await ensureMicrophonePermission();
if (!hasPermission) throw new Error("Microphone permission denied");
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) => {
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);
});
await invoke('start_microphone', { hostName, deviceName });
},
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;
}

View File

@@ -423,6 +423,13 @@
dependencies: dependencies:
"@tauri-apps/api" "^2.8.0" "@tauri-apps/api" "^2.8.0"
"@tauri-apps/plugin-notification@~2":
version "2.3.3"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz#01581e3abd3bd18200121b213d3e6ebff2967fe2"
integrity sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==
dependencies:
"@tauri-apps/api" "^2.8.0"
"@tauri-apps/plugin-opener@^2": "@tauri-apps/plugin-opener@^2":
version "2.5.2" version "2.5.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.2.tgz#6e2127d0ad7627a16103215ed596e4fa42bda199" resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.2.tgz#6e2127d0ad7627a16103215ed596e4fa42bda199"