Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d605c09bc | |||
| 74074115d2 | |||
| 12bb585081 | |||
| 665f450878 | |||
| e2e8217b86 | |||
| 6c6a23e672 | |||
| 16d179f8e8 | |||
| 12e91c0bbe | |||
| 8b9a40f059 | |||
| eac19823ea | |||
| a5e930ac17 | |||
| c3e0c47d32 | |||
| 8f9b823d69 | |||
| 7ad8ba82b6 | |||
| b44a785d20 | |||
| 331b1298f8 | |||
| 02ca3e3581 | |||
| eb8f702576 | |||
| bc8a7c5a32 | |||
| 1c67a5c97f |
@@ -58,6 +58,9 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# Claude Code per-project local settings
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
coverage/
|
coverage/
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.10.2",
|
"version": "0.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Generated
+812
-12
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chat-app-desktop"
|
name = "chat-app-desktop"
|
||||||
version = "0.10.2"
|
version = "0.11.0"
|
||||||
description = "ChatApp desktop client"
|
description = "ChatApp desktop client"
|
||||||
authors = ["Dennis"]
|
authors = ["Dennis"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -28,11 +28,42 @@ serde_json = "1"
|
|||||||
dryoc = { version = "0.7", default-features = false, features = ["serde"] }
|
dryoc = { version = "0.7", default-features = false, features = ["serde"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# PNG encoding for screen-source thumbnails returned by the
|
||||||
|
# `enumerate_screen_sources` command. `default-features = false` skips the
|
||||||
|
# image-format decoders we don't use (jpeg, gif, webp, …) — keeps the
|
||||||
|
# thumbnail command at ~200KB extra binary size.
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|
||||||
|
# Cross-platform screen + window enumeration and capture. Replaces direct
|
||||||
|
# Win32 GDI / macOS CoreGraphics / X11 calls with a small uniform API so
|
||||||
|
# the enumerate-sources command has one code path. The crate pulls in
|
||||||
|
# platform-specific backends automatically (~1.5MB binary growth on
|
||||||
|
# Windows). Marked optional so non-desktop targets don't compile it.
|
||||||
|
xcap = "0.0.14"
|
||||||
|
|
||||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-window-state = "2"
|
tauri-plugin-window-state = "2"
|
||||||
|
|
||||||
|
# Windows-only screen-source enumeration + thumbnail capture. Pulled in
|
||||||
|
# only on Windows so macOS + Linux builds stay slim. The enumerate command
|
||||||
|
# returns stub-empty on non-Windows until we add native equivalents.
|
||||||
|
[target."cfg(target_os = \"windows\")".dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Graphics_Gdi",
|
||||||
|
"Win32_UI_HiDpi",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# WASAPI loopback capture for system-audio screen-share. Lets the custom
|
||||||
|
# picker hand LiveKit a real audio track without falling back to the OS
|
||||||
|
# screen picker (which is the only way getDisplayMedia can grab system
|
||||||
|
# sound). Windows-only for v1; macOS needs ScreenCaptureKit-audio and
|
||||||
|
# Linux needs a PulseAudio / PipeWire path.
|
||||||
|
wasapi = "0.15"
|
||||||
|
|
||||||
# LiveKit client SDK — lives behind the `rust-livekit` feature flag so the
|
# LiveKit client SDK — lives behind the `rust-livekit` feature flag so the
|
||||||
# baseline build stays unaffected while the JS-SDK path is still the
|
# baseline build stays unaffected while the JS-SDK path is still the
|
||||||
# default. Pulls libwebrtc-rs which adds ~20MB to the binary and ~5-10min
|
# default. Pulls libwebrtc-rs which adds ~20MB to the binary and ~5-10min
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
mod crypto;
|
mod crypto;
|
||||||
|
mod screen_audio;
|
||||||
|
mod screen_capture;
|
||||||
|
mod screen_sources;
|
||||||
|
|
||||||
#[cfg(feature = "rust-livekit")]
|
#[cfg(feature = "rust-livekit")]
|
||||||
mod livekit_bridge;
|
mod livekit_bridge;
|
||||||
@@ -91,6 +94,14 @@ pub fn run() {
|
|||||||
crypto::crypto_box_seal,
|
crypto::crypto_box_seal,
|
||||||
crypto::crypto_box_seal_open,
|
crypto::crypto_box_seal_open,
|
||||||
crypto::crypto_pwhash,
|
crypto::crypto_pwhash,
|
||||||
|
screen_sources::enumerate_screen_sources,
|
||||||
|
screen_sources::list_screen_sources,
|
||||||
|
screen_sources::capture_screen_source_thumbnail,
|
||||||
|
screen_sources::capture_screen_source_thumbnail_bytes,
|
||||||
|
screen_capture::start_screen_capture,
|
||||||
|
screen_capture::stop_screen_capture,
|
||||||
|
screen_audio::start_system_audio_capture,
|
||||||
|
screen_audio::stop_system_audio_capture,
|
||||||
])
|
])
|
||||||
.plugin(tauri_plugin_notification::init());
|
.plugin(tauri_plugin_notification::init());
|
||||||
|
|
||||||
@@ -107,6 +118,14 @@ pub fn run() {
|
|||||||
crypto::crypto_box_seal,
|
crypto::crypto_box_seal,
|
||||||
crypto::crypto_box_seal_open,
|
crypto::crypto_box_seal_open,
|
||||||
crypto::crypto_pwhash,
|
crypto::crypto_pwhash,
|
||||||
|
screen_sources::enumerate_screen_sources,
|
||||||
|
screen_sources::list_screen_sources,
|
||||||
|
screen_sources::capture_screen_source_thumbnail,
|
||||||
|
screen_sources::capture_screen_source_thumbnail_bytes,
|
||||||
|
screen_capture::start_screen_capture,
|
||||||
|
screen_capture::stop_screen_capture,
|
||||||
|
screen_audio::start_system_audio_capture,
|
||||||
|
screen_audio::stop_system_audio_capture,
|
||||||
livekit_bridge::livekit_connect,
|
livekit_bridge::livekit_connect,
|
||||||
livekit_bridge::livekit_disconnect,
|
livekit_bridge::livekit_disconnect,
|
||||||
livekit_bridge::livekit_send_data,
|
livekit_bridge::livekit_send_data,
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
// Native system-audio capture for the custom screen-share picker. Without
|
||||||
|
// this path the picker has to fall back to getDisplayMedia whenever the
|
||||||
|
// user ticks "Mit System-Sound", because Chromium only wires audio into
|
||||||
|
// desktop captures that the OS picker produced. Here we grab the default
|
||||||
|
// render endpoint's loopback stream via WASAPI, convert it to 48kHz f32
|
||||||
|
// stereo, and ship the samples to the JS side through a Tauri Channel.
|
||||||
|
// An AudioWorklet on the frontend feeds them into a MediaStreamDestination
|
||||||
|
// so LiveKit publishes a plain ScreenShareAudio track.
|
||||||
|
//
|
||||||
|
// Windows-only for v1. macOS + Linux stubs return a clear error so the
|
||||||
|
// frontend can fall back cleanly on those platforms until their native
|
||||||
|
// paths ship (ScreenCaptureKit-audio / PipeWire).
|
||||||
|
|
||||||
|
#![allow(clippy::needless_return)]
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::thread;
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
|
// Output format we always deliver to the frontend. Picking a single fixed
|
||||||
|
// format means the AudioWorklet never has to renegotiate — it just assumes
|
||||||
|
// interleaved f32 stereo at 48kHz. WASAPI mix format is usually already
|
||||||
|
// this on Windows 10+, so the resample branch is rarely hit.
|
||||||
|
const OUTPUT_SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const OUTPUT_CHANNELS: u16 = 2;
|
||||||
|
|
||||||
|
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||||
|
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
|
||||||
|
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AudioFramePayload {
|
||||||
|
pub capture_id: u32,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u16,
|
||||||
|
/// Interleaved little-endian f32 stereo samples, base64-encoded.
|
||||||
|
/// Frontend decodes via `atob` → `Uint8Array` → `Float32Array` view.
|
||||||
|
/// Base64 is used instead of a raw `Vec<f32>` because Tauri Channel
|
||||||
|
/// serialises via JSON — a JSON array of floats balloons to ~2–3×
|
||||||
|
/// the byte count, and at 48kHz stereo that's enough IPC traffic
|
||||||
|
/// to matter.
|
||||||
|
pub samples_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a loopback capture of the default render endpoint and begin
|
||||||
|
/// streaming audio frames on the provided channel. Returns a numeric
|
||||||
|
/// capture id that must be handed to `stop_system_audio_capture` when
|
||||||
|
/// the share ends.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn start_system_audio_capture(
|
||||||
|
channel: Channel<AudioFramePayload>,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_clone = Arc::clone(&stop);
|
||||||
|
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name(format!("screen-audio-{capture_id}"))
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(err) =
|
||||||
|
windows_loopback::capture_loop(capture_id, channel, stop_clone)
|
||||||
|
{
|
||||||
|
eprintln!("screen-audio {capture_id}: {err}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("failed to spawn audio thread: {e}"))?;
|
||||||
|
|
||||||
|
sessions().lock().unwrap().insert(
|
||||||
|
capture_id,
|
||||||
|
Session {
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(capture_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
// Keep the `channel` binding alive so Tauri doesn't complain about
|
||||||
|
// an unused parameter on the non-Windows build.
|
||||||
|
let _ = channel;
|
||||||
|
Err("system audio capture only supported on Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down the capture for the given id. Safe to call on a missing id
|
||||||
|
/// (no-op) so the JS side doesn't have to track whether the stop has
|
||||||
|
/// already been issued by the screen-share teardown path.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stop_system_audio_capture(capture_id: u32) -> Result<(), String> {
|
||||||
|
let session = sessions().lock().unwrap().remove(&capture_id);
|
||||||
|
let Some(mut session) = session else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
session.stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(handle) = session.handle.take() {
|
||||||
|
// Best-effort join — the capture loop polls `stop` every event
|
||||||
|
// cycle (≤100ms) so this usually returns promptly. If the WASAPI
|
||||||
|
// call is wedged we'd rather drop the handle than hang the stop.
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Windows loopback implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_loopback {
|
||||||
|
use super::*;
|
||||||
|
use wasapi::{initialize_mta, Direction, SampleType, ShareMode};
|
||||||
|
|
||||||
|
pub fn capture_loop(
|
||||||
|
capture_id: u32,
|
||||||
|
channel: Channel<AudioFramePayload>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// COM must be initialised on every thread that touches WASAPI.
|
||||||
|
// MTA is the right model for a background capture thread — STA
|
||||||
|
// would require message pumping we don't want to add.
|
||||||
|
initialize_mta()
|
||||||
|
.ok()
|
||||||
|
.map_err(|e| format!("initialize_mta: {e:?}"))?;
|
||||||
|
|
||||||
|
let device = wasapi::get_default_device(&Direction::Render)
|
||||||
|
.map_err(|e| format!("get_default_device: {e:?}"))?;
|
||||||
|
let mut audio_client = device
|
||||||
|
.get_iaudioclient()
|
||||||
|
.map_err(|e| format!("get_iaudioclient: {e:?}"))?;
|
||||||
|
|
||||||
|
// Use the mix format that Windows is already pushing to the
|
||||||
|
// endpoint. Loopback capture won't convert for us — asking for a
|
||||||
|
// fixed format here makes Initialize() fail on non-matching
|
||||||
|
// hardware. We resample + channel-mix ourselves downstream.
|
||||||
|
let mix_format = audio_client
|
||||||
|
.get_mixformat()
|
||||||
|
.map_err(|e| format!("get_mixformat: {e:?}"))?;
|
||||||
|
let input_rate = mix_format.get_samplespersec();
|
||||||
|
let input_channels = mix_format.get_nchannels();
|
||||||
|
let bits_per_sample = mix_format.get_bitspersample();
|
||||||
|
let block_align = mix_format.get_blockalign();
|
||||||
|
let sample_type = mix_format.get_subformat().unwrap_or(SampleType::Int);
|
||||||
|
|
||||||
|
let (def_time, _min_time) = audio_client
|
||||||
|
.get_periods()
|
||||||
|
.map_err(|e| format!("get_periods: {e:?}"))?;
|
||||||
|
|
||||||
|
// Direction::Capture + loopback: WASAPI streams what Windows is
|
||||||
|
// sending to the speakers instead of what an input device is
|
||||||
|
// producing. Shared mode so we coexist with other apps.
|
||||||
|
audio_client
|
||||||
|
.initialize_client(
|
||||||
|
&mix_format,
|
||||||
|
def_time,
|
||||||
|
&Direction::Capture,
|
||||||
|
&ShareMode::Shared,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("initialize_client: {e:?}"))?;
|
||||||
|
|
||||||
|
let h_event = audio_client
|
||||||
|
.set_get_eventhandle()
|
||||||
|
.map_err(|e| format!("set_get_eventhandle: {e:?}"))?;
|
||||||
|
let capture_client = audio_client
|
||||||
|
.get_audiocaptureclient()
|
||||||
|
.map_err(|e| format!("get_audiocaptureclient: {e:?}"))?;
|
||||||
|
|
||||||
|
audio_client
|
||||||
|
.start_stream()
|
||||||
|
.map_err(|e| format!("start_stream: {e:?}"))?;
|
||||||
|
|
||||||
|
// Resampler state — last stereo frame from the previous buffer so
|
||||||
|
// linear interpolation at the buffer boundary doesn't click.
|
||||||
|
// Initialised to silence.
|
||||||
|
let mut last_stereo: [f32; 2] = [0.0, 0.0];
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
// 100ms timeout lets the loop check the stop flag even when
|
||||||
|
// the endpoint is silent (WASAPI doesn't signal the event at
|
||||||
|
// all for pure-silence streams on some driver versions).
|
||||||
|
if h_event.wait_for_event(100).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain all packets available since the last wake — there
|
||||||
|
// can be several queued if we were preempted.
|
||||||
|
loop {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let frames_available = match capture_client.get_next_nbr_frames() {
|
||||||
|
Ok(Some(n)) if n > 0 => n,
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"screen-audio {capture_id}: get_next_nbr_frames: {e:?}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes_needed =
|
||||||
|
frames_available as usize * block_align as usize;
|
||||||
|
let mut raw = vec![0u8; bytes_needed];
|
||||||
|
if let Err(e) = capture_client.read_from_device(&mut raw) {
|
||||||
|
eprintln!(
|
||||||
|
"screen-audio {capture_id}: read_from_device: {e:?}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode PCM into interleaved f32 at the device's native
|
||||||
|
// rate + channel count.
|
||||||
|
let decoded = decode_pcm(
|
||||||
|
&raw,
|
||||||
|
input_channels,
|
||||||
|
bits_per_sample,
|
||||||
|
&sample_type,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Channel-fold → 2ch, then resample → 48kHz.
|
||||||
|
let stereo = to_stereo(&decoded, input_channels);
|
||||||
|
let out_samples = resample_linear_stereo(
|
||||||
|
&stereo,
|
||||||
|
input_rate,
|
||||||
|
OUTPUT_SAMPLE_RATE,
|
||||||
|
&mut last_stereo,
|
||||||
|
);
|
||||||
|
|
||||||
|
if out_samples.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pack f32s as little-endian bytes then base64. IPC-wise
|
||||||
|
// this is ~1.3× the raw byte count versus 5–10× for a
|
||||||
|
// JSON array of floats, which is the difference between
|
||||||
|
// "fine" and "wastes a CPU core" at 48kHz stereo.
|
||||||
|
let mut bytes = Vec::with_capacity(out_samples.len() * 4);
|
||||||
|
for s in &out_samples {
|
||||||
|
bytes.extend_from_slice(&s.to_le_bytes());
|
||||||
|
}
|
||||||
|
let samples_b64 =
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
if channel
|
||||||
|
.send(AudioFramePayload {
|
||||||
|
capture_id,
|
||||||
|
sample_rate: OUTPUT_SAMPLE_RATE,
|
||||||
|
channels: OUTPUT_CHANNELS,
|
||||||
|
samples_base64: samples_b64,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
// Frontend went away — stop cleanly.
|
||||||
|
stop.store(true, Ordering::Relaxed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = audio_client.stop_stream();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a raw WASAPI buffer into interleaved f32 at the device's
|
||||||
|
// native channel count. Handles the three formats that actually show
|
||||||
|
// up on Windows render endpoints: f32 (most modern hardware), i16
|
||||||
|
// (older onboard codecs), and i32 (pro audio interfaces). Anything
|
||||||
|
// else falls through to zeros so a weird format doesn't crash the
|
||||||
|
// share — the user will notice silence and can retry.
|
||||||
|
fn decode_pcm(
|
||||||
|
raw: &[u8],
|
||||||
|
channels: u16,
|
||||||
|
bits_per_sample: u16,
|
||||||
|
sample_type: &SampleType,
|
||||||
|
) -> Vec<f32> {
|
||||||
|
match (sample_type, bits_per_sample) {
|
||||||
|
(SampleType::Float, 32) => {
|
||||||
|
let mut out = Vec::with_capacity(raw.len() / 4);
|
||||||
|
for chunk in raw.chunks_exact(4) {
|
||||||
|
out.push(f32::from_le_bytes([
|
||||||
|
chunk[0], chunk[1], chunk[2], chunk[3],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
(SampleType::Int, 16) => {
|
||||||
|
let scale = 1.0_f32 / (i16::MAX as f32);
|
||||||
|
let mut out = Vec::with_capacity(raw.len() / 2);
|
||||||
|
for chunk in raw.chunks_exact(2) {
|
||||||
|
let s = i16::from_le_bytes([chunk[0], chunk[1]]);
|
||||||
|
out.push(s as f32 * scale);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
(SampleType::Int, 32) => {
|
||||||
|
let scale = 1.0_f32 / (i32::MAX as f32);
|
||||||
|
let mut out = Vec::with_capacity(raw.len() / 4);
|
||||||
|
for chunk in raw.chunks_exact(4) {
|
||||||
|
let s = i32::from_le_bytes([
|
||||||
|
chunk[0], chunk[1], chunk[2], chunk[3],
|
||||||
|
]);
|
||||||
|
out.push(s as f32 * scale);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Unknown format — emit silence of the right frame count
|
||||||
|
// so downstream math stays correct.
|
||||||
|
let bytes_per_frame =
|
||||||
|
(bits_per_sample as usize / 8) * channels as usize;
|
||||||
|
let frames = if bytes_per_frame == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
raw.len() / bytes_per_frame
|
||||||
|
};
|
||||||
|
vec![0.0; frames * channels as usize]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down- or up-mix to stereo. Surround layouts fold L+R only (center
|
||||||
|
// + surrounds get dropped) which is the simplest defensible choice
|
||||||
|
// for screen-share audio — most content is LR-centric and a proper
|
||||||
|
// ITU-R BS.775 downmix would pull in matrix coefficients we'd rather
|
||||||
|
// avoid in v1.
|
||||||
|
fn to_stereo(interleaved: &[f32], channels: u16) -> Vec<f32> {
|
||||||
|
if channels == 0 || interleaved.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if channels == 2 {
|
||||||
|
return interleaved.to_vec();
|
||||||
|
}
|
||||||
|
let ch = channels as usize;
|
||||||
|
let frames = interleaved.len() / ch;
|
||||||
|
let mut out = Vec::with_capacity(frames * 2);
|
||||||
|
if channels == 1 {
|
||||||
|
for i in 0..frames {
|
||||||
|
let s = interleaved[i];
|
||||||
|
out.push(s);
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i in 0..frames {
|
||||||
|
let base = i * ch;
|
||||||
|
out.push(interleaved[base]);
|
||||||
|
out.push(interleaved[base + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear-interpolation resampler for interleaved stereo f32. Not the
|
||||||
|
// prettiest option theoretically, but at 44.1→48 the audible
|
||||||
|
// artefacts stay below threshold for speech + game/music content. The
|
||||||
|
// `last_stereo` state preserves the final frame across invocations so
|
||||||
|
// the interpolation at the buffer boundary doesn't produce a click.
|
||||||
|
fn resample_linear_stereo(
|
||||||
|
input_stereo: &[f32],
|
||||||
|
input_rate: u32,
|
||||||
|
output_rate: u32,
|
||||||
|
last_stereo: &mut [f32; 2],
|
||||||
|
) -> Vec<f32> {
|
||||||
|
if input_stereo.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if input_rate == output_rate {
|
||||||
|
last_stereo[0] = input_stereo[input_stereo.len() - 2];
|
||||||
|
last_stereo[1] = input_stereo[input_stereo.len() - 1];
|
||||||
|
return input_stereo.to_vec();
|
||||||
|
}
|
||||||
|
let ratio = output_rate as f64 / input_rate as f64;
|
||||||
|
let in_frames = input_stereo.len() / 2;
|
||||||
|
let out_frames = (in_frames as f64 * ratio).floor() as usize;
|
||||||
|
if out_frames == 0 {
|
||||||
|
last_stereo[0] = input_stereo[input_stereo.len() - 2];
|
||||||
|
last_stereo[1] = input_stereo[input_stereo.len() - 1];
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(out_frames * 2);
|
||||||
|
|
||||||
|
let prev_l = last_stereo[0];
|
||||||
|
let prev_r = last_stereo[1];
|
||||||
|
|
||||||
|
for i in 0..out_frames {
|
||||||
|
let src_pos = i as f64 / ratio;
|
||||||
|
let src_frame = src_pos.floor() as i64;
|
||||||
|
let frac = (src_pos - src_frame as f64) as f32;
|
||||||
|
|
||||||
|
// `src_frame == -1` comes up for the very first output frame
|
||||||
|
// when ratio > 1 — interpolate against the previous buffer's
|
||||||
|
// final sample to bridge the two.
|
||||||
|
let (l0, r0) = if src_frame < 0 {
|
||||||
|
(prev_l, prev_r)
|
||||||
|
} else {
|
||||||
|
let idx = (src_frame as usize).min(in_frames - 1) * 2;
|
||||||
|
(input_stereo[idx], input_stereo[idx + 1])
|
||||||
|
};
|
||||||
|
let next = ((src_frame + 1) as usize).min(in_frames - 1);
|
||||||
|
let l1 = input_stereo[next * 2];
|
||||||
|
let r1 = input_stereo[next * 2 + 1];
|
||||||
|
|
||||||
|
out.push(l0 + (l1 - l0) * frac);
|
||||||
|
out.push(r0 + (r1 - r0) * frac);
|
||||||
|
}
|
||||||
|
|
||||||
|
last_stereo[0] = input_stereo[input_stereo.len() - 2];
|
||||||
|
last_stereo[1] = input_stereo[input_stereo.len() - 1];
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
// Native screen / window capture pipeline. Spawns a Rust thread per active
|
||||||
|
// capture that grabs frames via xcap, downscales them to the user's target
|
||||||
|
// resolution, encodes JPEG, and streams each frame to the JS side through
|
||||||
|
// a Tauri `Channel<FramePayload>`. The JS end decodes into an
|
||||||
|
// ImageBitmap, draws to a <canvas>, and exposes the canvas as a
|
||||||
|
// MediaStream via captureStream() — that stream is what LiveKit publishes.
|
||||||
|
// Net result: the user picks a source in our custom picker and the share
|
||||||
|
// starts directly, without the OS/browser picker appearing.
|
||||||
|
//
|
||||||
|
// JPEG on the Rust side + decode on the JS side introduces a second
|
||||||
|
// encoding hop (LiveKit re-encodes VP9 later) but keeps IPC bandwidth
|
||||||
|
// manageable — raw RGBA at 1920×1080×30fps would be ~240MB/s and cannot
|
||||||
|
// go over Tauri's JSON-serialised IPC. JPEG frames at Q72 land around
|
||||||
|
// 50–150KB each, so 30fps = 2–5MB/s of base64 traffic, which is fine.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
|
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||||
|
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
|
||||||
|
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FramePayload {
|
||||||
|
pub capture_id: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// JPEG image bytes, base64-encoded (no data-URL prefix). Frontend
|
||||||
|
/// reconstructs via `Uint8Array.from(atob(...))` and decodes with
|
||||||
|
/// `createImageBitmap(blob)`.
|
||||||
|
pub jpeg_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Source {
|
||||||
|
Window(xcap::Window),
|
||||||
|
Monitor(xcap::Monitor),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a continuous capture for the given source id and begin streaming
|
||||||
|
/// JPEG frames via the provided channel. Returns a numeric capture id that
|
||||||
|
/// must be passed to `stop_screen_capture` to tear the pipeline down.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn start_screen_capture(
|
||||||
|
source_id: String,
|
||||||
|
max_width: u32,
|
||||||
|
max_height: u32,
|
||||||
|
fps: u32,
|
||||||
|
channel: Channel<FramePayload>,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
let clamped_fps = fps.clamp(5, 30);
|
||||||
|
let clamped_w = max_width.max(320).min(3840);
|
||||||
|
let clamped_h = max_height.max(180).min(2160);
|
||||||
|
|
||||||
|
// Probe once on the command thread so we can surface a clear error
|
||||||
|
// before spawning; the actual capture handle is re-resolved inside the
|
||||||
|
// worker thread (xcap::Window holds a Windows HWND which is !Send).
|
||||||
|
if find_source(&source_id).is_none() {
|
||||||
|
return Err(format!("screen source {source_id} not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_clone = Arc::clone(&stop);
|
||||||
|
let source_id_owned = source_id;
|
||||||
|
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name(format!("screen-capture-{capture_id}"))
|
||||||
|
.spawn(move || {
|
||||||
|
capture_loop(
|
||||||
|
source_id_owned,
|
||||||
|
clamped_w,
|
||||||
|
clamped_h,
|
||||||
|
clamped_fps,
|
||||||
|
capture_id,
|
||||||
|
channel,
|
||||||
|
stop_clone,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("failed to spawn capture thread: {e}"))?;
|
||||||
|
|
||||||
|
sessions().lock().unwrap().insert(
|
||||||
|
capture_id,
|
||||||
|
Session {
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(capture_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal the capture thread to stop and join it. Safe to call more than
|
||||||
|
/// once — missing ids are silently no-ops so the JS side doesn't need to
|
||||||
|
/// track whether a stop was already issued by the OS "stop sharing" path.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stop_screen_capture(capture_id: u32) -> Result<(), String> {
|
||||||
|
let session = sessions().lock().unwrap().remove(&capture_id);
|
||||||
|
let Some(mut session) = session else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
session.stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(handle) = session.handle.take() {
|
||||||
|
// Best-effort join. If the thread is stuck in a long OS capture
|
||||||
|
// call we don't want to hang the command — detach after a brief
|
||||||
|
// wait by dropping the handle.
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_loop(
|
||||||
|
source_id: String,
|
||||||
|
max_w: u32,
|
||||||
|
max_h: u32,
|
||||||
|
fps: u32,
|
||||||
|
capture_id: u32,
|
||||||
|
channel: Channel<FramePayload>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let frame_interval = Duration::from_nanos(1_000_000_000 / fps as u64);
|
||||||
|
let jpeg_quality: u8 = 72;
|
||||||
|
|
||||||
|
// Re-resolve the source inside the worker thread — xcap::Window holds
|
||||||
|
// an HWND which is !Send so we can't move it across threads. Caching
|
||||||
|
// the handle for the lifetime of the loop keeps per-frame cost to the
|
||||||
|
// actual pixel capture + encode.
|
||||||
|
let source = match find_source(&source_id) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
eprintln!("screen-capture {capture_id}: source vanished before capture started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
let frame_start = Instant::now();
|
||||||
|
|
||||||
|
let img_result = match &source {
|
||||||
|
Source::Window(w) => w.capture_image(),
|
||||||
|
Source::Monitor(m) => m.capture_image(),
|
||||||
|
};
|
||||||
|
let img = match img_result {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("screen-capture {capture_id}: capture failed: {err}");
|
||||||
|
// Brief backoff before retrying — transient Windows GDI
|
||||||
|
// errors (e.g. during screen lock) tend to recover within
|
||||||
|
// a frame or two.
|
||||||
|
thread::sleep(frame_interval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (raw_w, raw_h) = img.dimensions();
|
||||||
|
let (tgt_w, tgt_h) = scale_to_fit(raw_w, raw_h, max_w, max_h);
|
||||||
|
let scaled = if (tgt_w, tgt_h) != (raw_w, raw_h) {
|
||||||
|
image::imageops::resize(
|
||||||
|
&img,
|
||||||
|
tgt_w,
|
||||||
|
tgt_h,
|
||||||
|
image::imageops::FilterType::Triangle,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
|
||||||
|
// JPEG doesn't support alpha; strip it before encoding.
|
||||||
|
let rgb = rgba_to_rgb(&scaled);
|
||||||
|
|
||||||
|
let mut jpeg_buf: Vec<u8> = Vec::with_capacity((tgt_w * tgt_h) as usize);
|
||||||
|
{
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality);
|
||||||
|
if let Err(err) =
|
||||||
|
encoder.encode(&rgb, tgt_w, tgt_h, image::ExtendedColorType::Rgb8)
|
||||||
|
{
|
||||||
|
eprintln!("screen-capture {capture_id}: encode failed: {err}");
|
||||||
|
thread::sleep(frame_interval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let jpeg_base64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_buf);
|
||||||
|
|
||||||
|
let send_result = channel.send(FramePayload {
|
||||||
|
capture_id,
|
||||||
|
width: tgt_w,
|
||||||
|
height: tgt_h,
|
||||||
|
jpeg_base64,
|
||||||
|
});
|
||||||
|
if send_result.is_err() {
|
||||||
|
// Frontend went away (window closed, renderer crashed).
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pace to target framerate. If the capture + encode already took
|
||||||
|
// longer than one frame interval, yield a millisecond to avoid
|
||||||
|
// pegging a single core when the target is unreachable.
|
||||||
|
let elapsed = frame_start.elapsed();
|
||||||
|
if elapsed < frame_interval {
|
||||||
|
thread::sleep(frame_interval - elapsed);
|
||||||
|
} else {
|
||||||
|
thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgba_to_rgb(buf: &image::RgbaImage) -> Vec<u8> {
|
||||||
|
let (w, h) = buf.dimensions();
|
||||||
|
let mut out = Vec::with_capacity((w * h * 3) as usize);
|
||||||
|
for p in buf.pixels() {
|
||||||
|
out.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale_to_fit(w: u32, h: u32, max_w: u32, max_h: u32) -> (u32, u32) {
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return (w, h);
|
||||||
|
}
|
||||||
|
let scale = (max_w as f32 / w as f32)
|
||||||
|
.min(max_h as f32 / h as f32)
|
||||||
|
.min(1.0);
|
||||||
|
if scale >= 1.0 {
|
||||||
|
return (w, h);
|
||||||
|
}
|
||||||
|
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
|
||||||
|
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
|
||||||
|
(new_w, new_h)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_source(source_id: &str) -> Option<Source> {
|
||||||
|
if let Some(rest) = source_id.strip_prefix("window:") {
|
||||||
|
let raw = rest.strip_suffix(":0")?;
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let windows = xcap::Window::all().ok()?;
|
||||||
|
return windows
|
||||||
|
.into_iter()
|
||||||
|
.find(|w| w.id() == raw_id)
|
||||||
|
.map(Source::Window);
|
||||||
|
}
|
||||||
|
if let Some(rest) = source_id.strip_prefix("screen:") {
|
||||||
|
let raw = rest.strip_suffix(":0")?;
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let monitors = xcap::Monitor::all().ok()?;
|
||||||
|
return monitors
|
||||||
|
.into_iter()
|
||||||
|
.find(|m| m.id() == raw_id)
|
||||||
|
.map(Source::Monitor);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// Source enumeration for the Discord-style screen-share picker. Split
|
||||||
|
// into two commands on the slow-vs-fast axis:
|
||||||
|
//
|
||||||
|
// - list_screen_sources — metadata only (no thumbnails). Fast; the
|
||||||
|
// picker shows names + placeholders instantly.
|
||||||
|
// - capture_screen_source_thumbnail — one thumbnail at a time, keyed by
|
||||||
|
// the id returned from the list.
|
||||||
|
//
|
||||||
|
// The frontend fans out the thumbnail calls via Promise.all so Tauri's
|
||||||
|
// command thread pool captures them in parallel — wall-clock time ends up
|
||||||
|
// bounded by the *slowest* source rather than the sum of all captures.
|
||||||
|
// The `id` field is emitted in Chromium's internal desktopCapturer format
|
||||||
|
// ("screen:<id>:0" / "window:<hwnd>:0") so the JS side can try to pass it
|
||||||
|
// straight into getUserMedia's `chromeMediaSourceId` constraint, or use
|
||||||
|
// it as the source key for the native capture pipeline in screen_capture.
|
||||||
|
//
|
||||||
|
// xcap abstracts the platform-specific capture APIs (Windows GDI + DXGI,
|
||||||
|
// macOS CoreGraphics/ScreenCaptureKit, X11) so the code here stays flat.
|
||||||
|
// Thumbnails are captured at native resolution, then letterbox-downscaled
|
||||||
|
// to fit a 320×180 box to keep the base64 payload small.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use image::{ImageBuffer, Rgba};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
// Thumbnail dimensions tuned for the picker grid: even smaller than the
|
||||||
|
// first pass because we're now streaming raw JPEG bytes (no base64) —
|
||||||
|
// smaller payload = less IPC postMessage work on the main thread. At
|
||||||
|
// 192×108 / Q60 the typical window thumbnail is 5–10 KB and decodes to
|
||||||
|
// the grid in a frame or two.
|
||||||
|
const THUMB_MAX_W: u32 = 192;
|
||||||
|
const THUMB_MAX_H: u32 = 108;
|
||||||
|
const THUMB_JPEG_QUALITY: u8 = 60;
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScreenSource {
|
||||||
|
/// Chromium-format source id — stable within one enumeration call.
|
||||||
|
pub id: String,
|
||||||
|
/// Human-readable label for the picker (monitor name or window title).
|
||||||
|
pub name: String,
|
||||||
|
/// Discriminator for the grid grouping.
|
||||||
|
pub kind: &'static str,
|
||||||
|
/// Base64-encoded PNG, no data-URL prefix. None when the capture
|
||||||
|
/// fails (minimised window, permission-denied surface, transient
|
||||||
|
/// race). The UI renders a name-only card in that case.
|
||||||
|
pub thumbnail_png: Option<String>,
|
||||||
|
/// Native width of the full-res source — mostly informational, used
|
||||||
|
/// by the UI for aspect-ratio styling of the card.
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast metadata-only enumeration. No image capture happens here — that's
|
||||||
|
// why it returns in tens of milliseconds instead of the multi-second
|
||||||
|
// wait the single-shot enumerate_screen_sources command had.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_screen_sources() -> Result<Vec<ScreenSource>, String> {
|
||||||
|
let mut out: Vec<ScreenSource> = Vec::new();
|
||||||
|
append_monitor_metadata(&mut out);
|
||||||
|
append_window_metadata(&mut out);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot thumbnail capture by source id. Called N times in parallel from
|
||||||
|
// the frontend after the list lands so the total wall-clock is bounded by
|
||||||
|
// the slowest capture rather than the sum. Legacy base64 variant — kept
|
||||||
|
// for rollback; the preferred path is `capture_screen_source_thumbnail_bytes`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn capture_screen_source_thumbnail(source_id: String) -> Result<Option<String>, String> {
|
||||||
|
if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
|
||||||
|
return Ok(capture_monitor_by_id(raw));
|
||||||
|
}
|
||||||
|
if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
|
||||||
|
return Ok(capture_window_by_id(raw));
|
||||||
|
}
|
||||||
|
Err(format!("unknown source id format: {source_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary variant: returns raw JPEG bytes wrapped in `tauri::ipc::Response`
|
||||||
|
// so Tauri ships them over IPC without JSON-encoding / base64. On the JS
|
||||||
|
// side, `invoke` resolves to an ArrayBuffer that we wrap in a Blob and
|
||||||
|
// expose via `URL.createObjectURL` — skips the base64-decode step
|
||||||
|
// entirely and keeps the main thread responsive during fan-in.
|
||||||
|
//
|
||||||
|
// The return type MUST be `Response` directly (not `Result<Response, E>`):
|
||||||
|
// a Result wrapper forces Tauri to JSON-serialise the variant so the
|
||||||
|
// frontend gets a JSON object instead of raw bytes. Failures — bad id
|
||||||
|
// format, capture errors, source vanished — all funnel into an empty
|
||||||
|
// byte buffer; the caller treats `byteLength === 0` as the "no thumbnail"
|
||||||
|
// signal.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn capture_screen_source_thumbnail_bytes(source_id: String) -> tauri::ipc::Response {
|
||||||
|
let bytes = if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
|
||||||
|
capture_monitor_bytes_by_id(raw).unwrap_or_default()
|
||||||
|
} else if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
|
||||||
|
capture_window_bytes_by_id(raw).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
eprintln!("capture_screen_source_thumbnail_bytes: unknown id format: {source_id}");
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
tauri::ipc::Response::new(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_zero_suffix(s: &str) -> Option<&str> {
|
||||||
|
s.strip_suffix(":0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy one-shot all-in-one enumeration. Kept around so the frontend can
|
||||||
|
// fall back during rollout if the split pair throws; marked dead_code so
|
||||||
|
// the linker doesn't grumble when only the split variant is wired up.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn enumerate_screen_sources() -> Result<Vec<ScreenSource>, String> {
|
||||||
|
let mut out: Vec<ScreenSource> = Vec::new();
|
||||||
|
append_monitors(&mut out);
|
||||||
|
append_windows(&mut out);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Monitors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn append_monitor_metadata(out: &mut Vec<ScreenSource>) {
|
||||||
|
let monitors = match xcap::Monitor::all() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xcap Monitor::all failed: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (idx, m) in monitors.iter().enumerate() {
|
||||||
|
out.push(ScreenSource {
|
||||||
|
id: format!("screen:{}:0", m.id()),
|
||||||
|
name: monitor_label(m, idx),
|
||||||
|
kind: "screen",
|
||||||
|
thumbnail_png: None,
|
||||||
|
width: m.width(),
|
||||||
|
height: m.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn append_monitors(out: &mut Vec<ScreenSource>) {
|
||||||
|
let monitors = match xcap::Monitor::all() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xcap Monitor::all failed: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (idx, m) in monitors.iter().enumerate() {
|
||||||
|
out.push(ScreenSource {
|
||||||
|
id: format!("screen:{}:0", m.id()),
|
||||||
|
name: monitor_label(m, idx),
|
||||||
|
kind: "screen",
|
||||||
|
thumbnail_png: capture_monitor_thumbnail(m),
|
||||||
|
width: m.width(),
|
||||||
|
height: m.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_monitor_by_id(raw: &str) -> Option<String> {
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let monitors = xcap::Monitor::all().ok()?;
|
||||||
|
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
|
||||||
|
capture_monitor_thumbnail(&target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_monitor_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let monitors = xcap::Monitor::all().ok()?;
|
||||||
|
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
|
||||||
|
let image = target.capture_image().ok()?;
|
||||||
|
encode_scaled_jpeg_bytes(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn monitor_label(m: &xcap::Monitor, idx: usize) -> String {
|
||||||
|
let name = m.name();
|
||||||
|
if name.is_empty() {
|
||||||
|
format!("Bildschirm {}", idx + 1)
|
||||||
|
} else {
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_monitor_thumbnail(m: &xcap::Monitor) -> Option<String> {
|
||||||
|
let image = m.capture_image().ok()?;
|
||||||
|
encode_scaled_jpeg(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Windows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn append_window_metadata(out: &mut Vec<ScreenSource>) {
|
||||||
|
let windows = match xcap::Window::all() {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xcap Window::all failed: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for w in windows.iter() {
|
||||||
|
if !is_shareable_window(w) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title = w.title();
|
||||||
|
if title.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(ScreenSource {
|
||||||
|
id: format!("window:{}:0", w.id()),
|
||||||
|
name: title.to_string(),
|
||||||
|
kind: "window",
|
||||||
|
thumbnail_png: None,
|
||||||
|
width: w.width(),
|
||||||
|
height: w.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn append_windows(out: &mut Vec<ScreenSource>) {
|
||||||
|
let windows = match xcap::Window::all() {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xcap Window::all failed: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for w in windows.iter() {
|
||||||
|
if !is_shareable_window(w) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title = w.title();
|
||||||
|
if title.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(ScreenSource {
|
||||||
|
id: format!("window:{}:0", w.id()),
|
||||||
|
name: title.to_string(),
|
||||||
|
kind: "window",
|
||||||
|
thumbnail_png: capture_window_thumbnail(w),
|
||||||
|
width: w.width(),
|
||||||
|
height: w.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_window_by_id(raw: &str) -> Option<String> {
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let windows = xcap::Window::all().ok()?;
|
||||||
|
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
|
||||||
|
capture_window_thumbnail(&target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_window_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let windows = xcap::Window::all().ok()?;
|
||||||
|
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
|
||||||
|
let image = target.capture_image().ok()?;
|
||||||
|
encode_scaled_jpeg_bytes(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_shareable_window(w: &xcap::Window) -> bool {
|
||||||
|
if w.is_minimized() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let width = w.width();
|
||||||
|
let height = w.height();
|
||||||
|
// Tooltips, invisible tray-helpers, etc. sit at or near zero size —
|
||||||
|
// they'd clutter the picker grid and usually can't be captured anyway.
|
||||||
|
if width < 80 || height < 60 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_window_thumbnail(w: &xcap::Window) -> Option<String> {
|
||||||
|
let image = w.capture_image().ok()?;
|
||||||
|
encode_scaled_jpeg(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scaling + encoding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Letterbox-shrink the captured image so the long edge is at most
|
||||||
|
// THUMB_MAX_W / THUMB_MAX_H. Keeps aspect ratio, skips upscaling entirely
|
||||||
|
// (tiny windows stay their captured size). Returns raw JPEG bytes — the
|
||||||
|
// binary-IPC path ships these directly, the legacy base64 wrapper
|
||||||
|
// (`encode_scaled_jpeg`) wraps in base64 for the old command.
|
||||||
|
fn encode_scaled_jpeg_bytes(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<Vec<u8>> {
|
||||||
|
let (w, h) = src.dimensions();
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let scale = (THUMB_MAX_W as f32 / w as f32)
|
||||||
|
.min(THUMB_MAX_H as f32 / h as f32)
|
||||||
|
.min(1.0);
|
||||||
|
let scaled = if scale < 1.0 {
|
||||||
|
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
|
||||||
|
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
|
||||||
|
image::imageops::resize(&src, new_w, new_h, image::imageops::FilterType::Triangle)
|
||||||
|
} else {
|
||||||
|
src
|
||||||
|
};
|
||||||
|
// JPEG encoder doesn't accept RGBA — strip alpha into a packed RGB
|
||||||
|
// buffer first. Alpha carries no info for a visible thumbnail anyway.
|
||||||
|
let (sw, sh) = (scaled.width(), scaled.height());
|
||||||
|
let mut rgb: Vec<u8> = Vec::with_capacity((sw * sh * 3) as usize);
|
||||||
|
for p in scaled.pixels() {
|
||||||
|
rgb.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
|
||||||
|
}
|
||||||
|
let mut buf: Vec<u8> = Vec::with_capacity((sw * sh / 8) as usize);
|
||||||
|
{
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
let mut encoder = JpegEncoder::new_with_quality(&mut buf, THUMB_JPEG_QUALITY);
|
||||||
|
encoder
|
||||||
|
.encode(&rgb, sw, sh, image::ExtendedColorType::Rgb8)
|
||||||
|
.ok()?;
|
||||||
|
}
|
||||||
|
Some(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy base64 wrapper — used by `capture_screen_source_thumbnail`
|
||||||
|
// (Result<Option<String>, String>) which predates the binary variant.
|
||||||
|
fn encode_scaled_jpeg(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
|
||||||
|
let bytes = encode_scaled_jpeg_bytes(src)?;
|
||||||
|
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ChatApp",
|
"productName": "ChatApp",
|
||||||
"version": "0.10.2",
|
"version": "0.11.0",
|
||||||
"identifier": "com.meinname.chatapp",
|
"identifier": "com.meinname.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm vite:dev",
|
"beforeDevCommand": "pnpm vite:dev",
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ interface Props {
|
|||||||
deafened: boolean;
|
deafened: boolean;
|
||||||
onToggleMute: () => void;
|
onToggleMute: () => void;
|
||||||
onToggleShare: () => void;
|
onToggleShare: () => void;
|
||||||
|
/** Right-click on the share button opens the quality picker dialog while
|
||||||
|
* left-click just starts with last-used settings. Optional so pages that
|
||||||
|
* don't need the advanced path (mobile, etc.) can skip it. */
|
||||||
|
onShareContextMenu?: (e: React.MouseEvent) => void;
|
||||||
onToggleVideo?: () => void;
|
onToggleVideo?: () => void;
|
||||||
onToggleDeafen: () => void;
|
onToggleDeafen: () => void;
|
||||||
onHangup: () => void;
|
onHangup: () => void;
|
||||||
@@ -27,6 +31,7 @@ interface Props {
|
|||||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||||
onToggleSoundboard?: () => void;
|
onToggleSoundboard?: () => void;
|
||||||
soundboardOpen?: boolean;
|
soundboardOpen?: boolean;
|
||||||
|
participantsOpen?: boolean;
|
||||||
/** Compact variant used inside the docked call (36px buttons). */
|
/** Compact variant used inside the docked call (36px buttons). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** Glass variant used when controls float on fullscreen cinema mode. */
|
/** Glass variant used when controls float on fullscreen cinema mode. */
|
||||||
@@ -42,12 +47,14 @@ export function CallControls({
|
|||||||
deafened,
|
deafened,
|
||||||
onToggleMute,
|
onToggleMute,
|
||||||
onToggleShare,
|
onToggleShare,
|
||||||
|
onShareContextMenu,
|
||||||
onToggleVideo,
|
onToggleVideo,
|
||||||
onToggleDeafen,
|
onToggleDeafen,
|
||||||
onHangup,
|
onHangup,
|
||||||
onOpenParticipants,
|
onOpenParticipants,
|
||||||
onToggleSoundboard,
|
onToggleSoundboard,
|
||||||
soundboardOpen = false,
|
soundboardOpen = false,
|
||||||
|
participantsOpen = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
glass = false,
|
glass = false,
|
||||||
disabledMedia = false,
|
disabledMedia = false,
|
||||||
@@ -111,6 +118,7 @@ export function CallControls({
|
|||||||
active={sharing}
|
active={sharing}
|
||||||
activeTone="accent"
|
activeTone="accent"
|
||||||
onClick={onToggleShare}
|
onClick={onToggleShare}
|
||||||
|
{...(onShareContextMenu ? { onContextMenu: onShareContextMenu } : {})}
|
||||||
disabled={disabledMedia}
|
disabled={disabledMedia}
|
||||||
glass={glass}
|
glass={glass}
|
||||||
className={btnSize}
|
className={btnSize}
|
||||||
@@ -137,6 +145,9 @@ export function CallControls({
|
|||||||
<CallButton
|
<CallButton
|
||||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
onClick={onOpenParticipants}
|
onClick={onOpenParticipants}
|
||||||
|
active={participantsOpen}
|
||||||
|
activeTone="accent"
|
||||||
|
dataTrigger="participants"
|
||||||
glass={glass}
|
glass={glass}
|
||||||
className={btnSize}
|
className={btnSize}
|
||||||
>
|
>
|
||||||
@@ -159,24 +170,30 @@ export function CallControls({
|
|||||||
interface CallButtonProps {
|
interface CallButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
activeTone?: 'accent' | 'danger';
|
activeTone?: 'accent' | 'danger';
|
||||||
tone?: 'default' | 'danger';
|
tone?: 'default' | 'danger';
|
||||||
glass?: boolean;
|
glass?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Stable trigger id so portals (popovers) can skip outside-click dismiss
|
||||||
|
* when the user is toggling their own trigger. */
|
||||||
|
dataTrigger?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallButton({
|
function CallButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
disabled,
|
disabled,
|
||||||
active,
|
active,
|
||||||
activeTone = 'accent',
|
activeTone = 'accent',
|
||||||
tone = 'default',
|
tone = 'default',
|
||||||
glass = false,
|
glass = false,
|
||||||
className = '',
|
className = '',
|
||||||
|
dataTrigger,
|
||||||
children,
|
children,
|
||||||
}: CallButtonProps) {
|
}: CallButtonProps) {
|
||||||
const base =
|
const base =
|
||||||
@@ -200,11 +217,13 @@ function CallButton({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
title={label}
|
title={label}
|
||||||
className={`${base} ${toneClass} ${className}`}
|
className={`${base} ${toneClass} ${className}`}
|
||||||
|
{...(dataTrigger ? { [`data-${dataTrigger}-trigger`]: 'true' } : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const small = size === 'small';
|
const small = size === 'small';
|
||||||
const borderClass = speaking
|
// Split the speaking indicator per-mode so we don't stack a tile border
|
||||||
|
// + inset glow on top of the avatar pulse (visual double-chrome). Video
|
||||||
|
// tiles get the border (the avatar is hidden behind the stream so the
|
||||||
|
// pulse wouldn't be visible anyway); audio tiles rely on the avatar
|
||||||
|
// pulse rendered inside AudioContent.
|
||||||
|
const videoSpeaking = speaking && video;
|
||||||
|
const borderClass = videoSpeaking
|
||||||
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
|
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
|
||||||
: focused
|
: focused
|
||||||
? 'border-accent'
|
? 'border-accent'
|
||||||
@@ -98,9 +104,10 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
<AudioContent {...props} small={small} />
|
<AudioContent {...props} small={small} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Speaking indicator visible regardless of content type (video or
|
{/* Video-only speaking indicator. Audio tiles use the avatar pulse
|
||||||
audio). z-10 ensures it sits above the video element. */}
|
from AudioContent so we don't double-render chrome. z-10 keeps
|
||||||
{speaking && (
|
it above the video element. */}
|
||||||
|
{videoSpeaking && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
|
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -158,6 +158,7 @@ function PipCall() {
|
|||||||
const active =
|
const active =
|
||||||
state.kind === 'connected' ||
|
state.kind === 'connected' ||
|
||||||
state.kind === 'connecting' ||
|
state.kind === 'connecting' ||
|
||||||
|
state.kind === 'reconnecting' ||
|
||||||
state.kind === 'outgoing';
|
state.kind === 'outgoing';
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
|
|
||||||
@@ -172,6 +173,13 @@ function PipCall() {
|
|||||||
: conv?.peer?.displayName ?? '—';
|
: conv?.peer?.displayName ?? '—';
|
||||||
const participantCount = 1 + remoteParticipants.length;
|
const participantCount = 1 + remoteParticipants.length;
|
||||||
const someoneSharing = remoteScreenShares.length > 0;
|
const someoneSharing = remoteScreenShares.length > 0;
|
||||||
|
// Duration ticks while connected or reconnecting (LiveKit holds the room
|
||||||
|
// across reconnects, so the timer shouldn't reset on a wobble). Absent
|
||||||
|
// on outgoing/connecting where the call hasn't started yet.
|
||||||
|
const startedAt =
|
||||||
|
state.kind === 'connected' || state.kind === 'reconnecting'
|
||||||
|
? state.startedAt
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -196,7 +204,11 @@ function PipCall() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
|
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
|
||||||
/>
|
/>
|
||||||
<span>Live · {t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}</span>
|
<span className="tabular-nums">
|
||||||
|
{startedAt
|
||||||
|
? <PipDuration startedAt={startedAt} />
|
||||||
|
: t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -213,3 +225,24 @@ function PipCall() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live-ticking `mm:ss` / `hh:mm:ss` for the PiP. Duplicated from InCallPanel
|
||||||
|
// deliberately — the two widgets have different typography + tabular
|
||||||
|
// contexts, and extracting a shared component would be heavier than the
|
||||||
|
// 8-line countup it replaces.
|
||||||
|
function PipDuration({ startedAt }: { startedAt: string }) {
|
||||||
|
const [, tick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => tick((v) => v + 1), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
const total = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000),
|
||||||
|
);
|
||||||
|
const hh = Math.floor(total / 3600);
|
||||||
|
const mm = Math.floor((total % 3600) / 60);
|
||||||
|
const ss = total % 60;
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return <>{hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${pad(mm)}:${pad(ss)}`}</>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
import type { RemoteParticipant, Room } from 'livekit-client';
|
import type { RemoteParticipant, Room } from 'livekit-client';
|
||||||
import { Track } from 'livekit-client';
|
import { Track } from 'livekit-client';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
@@ -11,12 +11,18 @@ import {
|
|||||||
type PttSettings,
|
type PttSettings,
|
||||||
subscribePttSettings,
|
subscribePttSettings,
|
||||||
} from '../lib/pttSettings';
|
} from '../lib/pttSettings';
|
||||||
|
import {
|
||||||
|
listSounds,
|
||||||
|
subscribeSoundboardChanges,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||||
import { CallControls } from './CallControls';
|
import { CallControls } from './CallControls';
|
||||||
import { CallParticipantTile } from './CallParticipantTile';
|
import { CallParticipantTile } from './CallParticipantTile';
|
||||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||||
|
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||||
import { ScreenShareDialog } from './ScreenShareDialog';
|
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
||||||
|
import { ScreenSourcePicker } from './ScreenSourcePicker';
|
||||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||||
import { SoundboardPanel } from './SoundboardPanel';
|
import { SoundboardPanel } from './SoundboardPanel';
|
||||||
|
|
||||||
@@ -74,23 +80,105 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
hangup,
|
hangup,
|
||||||
setCallMode,
|
setCallMode,
|
||||||
setFocusedId,
|
setFocusedId,
|
||||||
|
micError,
|
||||||
|
clearMicError,
|
||||||
|
retryMic,
|
||||||
|
dismissedShareUserIds,
|
||||||
} = useCall();
|
} = useCall();
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
const myId = session?.user.id ?? null;
|
const myId = session?.user.id ?? null;
|
||||||
const activeSpeakers = useActiveSpeakers(room);
|
const activeSpeakers = useActiveSpeakers(room);
|
||||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
||||||
|
const [participantsOpen, setParticipantsOpen] = useState(false);
|
||||||
const [volumeMenu, setVolumeMenu] = useState<
|
const [volumeMenu, setVolumeMenu] = useState<
|
||||||
{ userId: string; displayName: string; x: number; y: number } | null
|
{ userId: string; displayName: string; x: number; y: number } | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [shareMenu, setShareMenu] = useState<
|
||||||
|
{ userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null
|
||||||
|
>(null);
|
||||||
|
// Soundboard-count so the in-call bar only surfaces the music button when
|
||||||
|
// the user actually has something to play. Matches Discord's "hide soundboard
|
||||||
|
// when empty" behaviour — no point dangling a button that opens to a blank
|
||||||
|
// "Keine Sounds" popover. Subscribes live so a sound added mid-call makes
|
||||||
|
// the button pop in without reopening the call.
|
||||||
|
const [soundboardCount, setSoundboardCount] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const all = await listSounds();
|
||||||
|
if (!cancelled) setSoundboardCount(all.length);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setSoundboardCount(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void refresh();
|
||||||
|
const unsub = subscribeSoundboardChanges(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsub();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
// Close the popover if the user just cleared their last sound while it was
|
||||||
|
// open — keeps the panel from lingering over an empty list.
|
||||||
|
useEffect(() => {
|
||||||
|
if (soundboardCount === 0) setSoundboardOpen(false);
|
||||||
|
}, [soundboardCount]);
|
||||||
|
|
||||||
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
|
// Active-speaker auto-focus uses "who most recently started speaking"
|
||||||
|
// rather than "exactly one speaker" — matches Discord more closely and
|
||||||
|
// handles the case where two people talk briefly without the focus
|
||||||
|
// collapsing to nobody.
|
||||||
|
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
|
||||||
|
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
for (const id of activeSpeakers) {
|
||||||
|
if (!prevActiveSpeakersRef.current.has(id)) {
|
||||||
|
setLastStartedSpeakerId(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevActiveSpeakersRef.current = new Set(activeSpeakers);
|
||||||
|
}, [activeSpeakers]);
|
||||||
|
|
||||||
|
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
||||||
|
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
||||||
|
// watching). Self-tiles get no menu — no volume to control, and you can
|
||||||
|
// stop your own share from the control bar.
|
||||||
|
const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => {
|
||||||
if (tile.self) return;
|
if (tile.self) return;
|
||||||
if (tile.kind !== 'user') return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setVolumeMenu({
|
if (tile.kind === 'user') {
|
||||||
|
setShareMenu(null);
|
||||||
|
setVolumeMenu({
|
||||||
|
userId: tile.userId,
|
||||||
|
displayName: tile.displayName,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Screen-tile. Check whether the participant has a published screen-share
|
||||||
|
// audio track so the menu can hide the volume/mute rows when there's
|
||||||
|
// nothing to control.
|
||||||
|
const participant = remoteParticipants.find((p) => p.identity === tile.userId);
|
||||||
|
let hasAudio = false;
|
||||||
|
if (participant) {
|
||||||
|
for (const pub of participant.audioTrackPublications.values()) {
|
||||||
|
if (pub.source === Track.Source.ScreenShareAudio) {
|
||||||
|
hasAudio = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVolumeMenu(null);
|
||||||
|
setShareMenu({
|
||||||
userId: tile.userId,
|
userId: tile.userId,
|
||||||
displayName: tile.displayName,
|
displayName: tile.displayName.replace(/\s·\sBildschirm$/, ''),
|
||||||
|
hasAudio,
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
});
|
});
|
||||||
@@ -99,6 +187,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
const active =
|
const active =
|
||||||
(state.kind === 'connected' ||
|
(state.kind === 'connected' ||
|
||||||
state.kind === 'connecting' ||
|
state.kind === 'connecting' ||
|
||||||
|
state.kind === 'reconnecting' ||
|
||||||
state.kind === 'outgoing') &&
|
state.kind === 'outgoing') &&
|
||||||
state.conversationId === conversation.id;
|
state.conversationId === conversation.id;
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
@@ -114,9 +203,20 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
isCameraEnabled,
|
isCameraEnabled,
|
||||||
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
// Sharer ids that survived the user's dismiss-set. If the user did
|
||||||
|
// "Zuschauen beenden" on someone's share, they drop out of the tile
|
||||||
|
// grid until that sharer stops + restarts (TrackUnsubscribed clears
|
||||||
|
// dismissedShareUserIds — see CallContext).
|
||||||
|
remoteSharerIds: new Set(
|
||||||
|
remoteScreenShares
|
||||||
|
.map((s) => s.participantId)
|
||||||
|
.filter((id) => !dismissedShareUserIds.has(id)),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Duration keeps ticking during reconnecting so the user sees the call is
|
||||||
|
// still alive — but the status label below takes precedence in the header
|
||||||
|
// so the "Verbinde neu…" message is prominent, not buried under the timer.
|
||||||
const duration =
|
const duration =
|
||||||
state.kind === 'connected'
|
state.kind === 'connected'
|
||||||
? <LiveDuration startedAt={state.startedAt} />
|
? <LiveDuration startedAt={state.startedAt} />
|
||||||
@@ -127,15 +227,17 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
? t('app:call.outgoing_ringing')
|
? t('app:call.outgoing_ringing')
|
||||||
: state.kind === 'connecting'
|
: state.kind === 'connecting'
|
||||||
? t('app:call.connecting')
|
? t('app:call.connecting')
|
||||||
: remoteParticipants.length === 0
|
: state.kind === 'reconnecting'
|
||||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
? t('app:call.reconnecting', { defaultValue: 'Verbinde neu…' })
|
||||||
: t('app:call.connected');
|
: remoteParticipants.length === 0
|
||||||
|
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||||
|
: t('app:call.connected');
|
||||||
|
|
||||||
// A screen-share tile becomes the auto-focus target when no one explicitly
|
// Screen shares no longer auto-promote — the user opts in by clicking the
|
||||||
// picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
|
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
||||||
// distinguish a user's own avatar tile from their screen tile.
|
// plays. Focus falls back to the first tile so focus-mode always has
|
||||||
const screenTile = tiles.find((p) => p.kind === 'screen');
|
// something to show when no tile was explicitly picked.
|
||||||
const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null;
|
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
||||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||||
|
|
||||||
const controls = (
|
const controls = (
|
||||||
@@ -145,40 +247,86 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
video={isCameraEnabled}
|
video={isCameraEnabled}
|
||||||
deafened={isDeafened}
|
deafened={isDeafened}
|
||||||
onToggleMute={toggleMute}
|
onToggleMute={toggleMute}
|
||||||
|
// Click opens the Discord-style source picker (thumbnails + quality +
|
||||||
|
// audio). Clicking again while a share is live stops it. Right-click
|
||||||
|
// also opens the picker in case the user wants to swap sources.
|
||||||
onToggleShare={() => {
|
onToggleShare={() => {
|
||||||
if (isScreenSharing) {
|
if (isScreenSharing) {
|
||||||
void stopScreenShare();
|
void stopScreenShare();
|
||||||
} else {
|
} else {
|
||||||
setShareDialogOpen(true);
|
setPickerOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onShareContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isScreenSharing) setPickerOpen(true);
|
||||||
|
}}
|
||||||
onToggleVideo={() => void toggleCamera()}
|
onToggleVideo={() => void toggleCamera()}
|
||||||
onToggleDeafen={toggleDeafen}
|
onToggleDeafen={toggleDeafen}
|
||||||
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
|
onOpenParticipants={() => setParticipantsOpen((v) => !v)}
|
||||||
soundboardOpen={soundboardOpen}
|
participantsOpen={participantsOpen}
|
||||||
|
// Soundboard-Button nur wenn mind. ein Sound existiert. Bis der Count
|
||||||
|
// aus IndexedDB geladen ist (null), auch nicht rendern — verhindert
|
||||||
|
// einen Flash des Buttons beim Call-Start wenn der User eh keine
|
||||||
|
// Sounds hat.
|
||||||
|
{...(soundboardCount && soundboardCount > 0
|
||||||
|
? {
|
||||||
|
onToggleSoundboard: () => setSoundboardOpen((v) => !v),
|
||||||
|
soundboardOpen,
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
onHangup={() => void hangup()}
|
onHangup={() => void hangup()}
|
||||||
compact={callMode !== 'fullscreen'}
|
compact={callMode !== 'fullscreen'}
|
||||||
glass={callMode === 'fullscreen'}
|
glass={callMode === 'fullscreen'}
|
||||||
disabledMedia={state.kind !== 'connected'}
|
disabledMedia={state.kind !== 'connected' && state.kind !== 'reconnecting'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only participant-tiles feed the popover (screen-share tiles aren't
|
||||||
|
// people). Own row is always first, rest follows conversation order.
|
||||||
|
const participantRows: ParticipantRow[] = tiles
|
||||||
|
.filter((t) => t.kind === 'user')
|
||||||
|
.map((t) => ({
|
||||||
|
userId: t.userId,
|
||||||
|
displayName: t.displayName,
|
||||||
|
avatarUrl: t.avatarUrl,
|
||||||
|
self: t.self,
|
||||||
|
muted: t.muted,
|
||||||
|
deafened: t.deafened,
|
||||||
|
}));
|
||||||
|
|
||||||
if (callMode === 'fullscreen') {
|
if (callMode === 'fullscreen') {
|
||||||
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
|
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
||||||
// someone is sharing a screen, OR exactly one non-self speaker is talking
|
// the person who most recently started speaking (tracked in
|
||||||
// (auto-promote). Without that we show an even grid of all participants
|
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
|
||||||
// (Discord default). Clicking a tile switches to the big-speaker layout.
|
// trigger; they stay as equal-size grid tiles until the user clicks
|
||||||
const speakingNonSelf = tiles.filter(
|
// one. "Most recent speaker" beats "exactly one currently speaking"
|
||||||
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user',
|
// because two people briefly overlapping shouldn't kick us out of
|
||||||
);
|
// auto-focus.
|
||||||
const autoSpeaker =
|
const autoSpeaker =
|
||||||
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1
|
focusedId === null && lastStartedSpeakerId !== null
|
||||||
? speakingNonSelf[0]
|
? tiles.find(
|
||||||
|
(t) =>
|
||||||
|
t.kind === 'user' &&
|
||||||
|
!t.self &&
|
||||||
|
t.userId === lastStartedSpeakerId,
|
||||||
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
|
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
|
||||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{micError && (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 top-5 z-[65] flex justify-center px-4">
|
||||||
|
<div className="pointer-events-auto max-w-[520px] w-full">
|
||||||
|
<MicErrorBanner
|
||||||
|
message={micError}
|
||||||
|
onRetry={() => void retryMic()}
|
||||||
|
onDismiss={clearMicError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<FullscreenCall
|
<FullscreenCall
|
||||||
tiles={tiles}
|
tiles={tiles}
|
||||||
speaker={effectiveSpeaker}
|
speaker={effectiveSpeaker}
|
||||||
@@ -191,8 +339,18 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
// Toggle: click the already-focused tile to return to grid.
|
// Toggle: click the already-focused tile to return to grid.
|
||||||
setFocusedId(focusedId === id ? null : id);
|
setFocusedId(focusedId === id ? null : id);
|
||||||
}}
|
}}
|
||||||
onTileContextMenu={openVolumeMenu}
|
onTileContextMenu={openTileContextMenu}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
|
// Any active popover / menu / banner pins the controls so the user
|
||||||
|
// can interact with them without the chrome fading out under their
|
||||||
|
// cursor while they're mid-action.
|
||||||
|
keepControlsVisible={
|
||||||
|
soundboardOpen ||
|
||||||
|
participantsOpen ||
|
||||||
|
volumeMenu !== null ||
|
||||||
|
shareMenu !== null ||
|
||||||
|
micError !== null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{volumeMenu && (
|
{volumeMenu && (
|
||||||
<ParticipantVolumeMenu
|
<ParticipantVolumeMenu
|
||||||
@@ -203,10 +361,26 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setVolumeMenu(null)}
|
onClose={() => setVolumeMenu(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{shareMenu && (
|
||||||
|
<ScreenShareContextMenu
|
||||||
|
userId={shareMenu.userId}
|
||||||
|
displayName={shareMenu.displayName}
|
||||||
|
hasAudio={shareMenu.hasAudio}
|
||||||
|
x={shareMenu.x}
|
||||||
|
y={shareMenu.y}
|
||||||
|
onClose={() => setShareMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SoundboardPopover
|
<SoundboardPopover
|
||||||
open={soundboardOpen}
|
open={soundboardOpen}
|
||||||
onClose={() => setSoundboardOpen(false)}
|
onClose={() => setSoundboardOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<ParticipantsPopover
|
||||||
|
open={participantsOpen}
|
||||||
|
rows={participantRows}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
onClose={() => setParticipantsOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,6 +427,14 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
<ModeToggles mode={callMode} onChange={setCallMode} />
|
<ModeToggles mode={callMode} onChange={setCallMode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{micError && (
|
||||||
|
<MicErrorBanner
|
||||||
|
message={micError}
|
||||||
|
onRetry={() => void retryMic()}
|
||||||
|
onDismiss={clearMicError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<CallStage
|
<CallStage
|
||||||
tiles={tiles}
|
tiles={tiles}
|
||||||
speaker={speaker}
|
speaker={speaker}
|
||||||
@@ -265,7 +447,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
setFocusedId(id);
|
setFocusedId(id);
|
||||||
if (callMode === 'grid') setCallMode('focus');
|
if (callMode === 'grid') setCallMode('focus');
|
||||||
}}
|
}}
|
||||||
onTileContextMenu={openVolumeMenu}
|
onTileContextMenu={openTileContextMenu}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -273,9 +455,9 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
|
|
||||||
<PttHint />
|
<PttHint />
|
||||||
|
|
||||||
<ScreenShareDialog
|
<ScreenSourcePicker
|
||||||
open={shareDialogOpen}
|
open={pickerOpen}
|
||||||
onClose={() => setShareDialogOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
onStart={async (opts) => {
|
onStart={async (opts) => {
|
||||||
await startScreenShare(opts);
|
await startScreenShare(opts);
|
||||||
}}
|
}}
|
||||||
@@ -291,10 +473,28 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{shareMenu && (
|
||||||
|
<ScreenShareContextMenu
|
||||||
|
userId={shareMenu.userId}
|
||||||
|
displayName={shareMenu.displayName}
|
||||||
|
hasAudio={shareMenu.hasAudio}
|
||||||
|
x={shareMenu.x}
|
||||||
|
y={shareMenu.y}
|
||||||
|
onClose={() => setShareMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SoundboardPopover
|
<SoundboardPopover
|
||||||
open={soundboardOpen}
|
open={soundboardOpen}
|
||||||
onClose={() => setSoundboardOpen(false)}
|
onClose={() => setSoundboardOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ParticipantsPopover
|
||||||
|
open={participantsOpen}
|
||||||
|
rows={participantRows}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
onClose={() => setParticipantsOpen(false)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -583,6 +783,7 @@ function TileRender({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
||||||
>
|
>
|
||||||
<ScreenShareViewer
|
<ScreenShareViewer
|
||||||
@@ -761,8 +962,13 @@ interface FullscreenProps {
|
|||||||
onFocusTile: (id: string) => void;
|
onFocusTile: (id: string) => void;
|
||||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||||
controls: React.ReactNode;
|
controls: React.ReactNode;
|
||||||
|
/** When true, controls stay visible regardless of mouse idle (used while
|
||||||
|
* a popover / menu / error banner is open). */
|
||||||
|
keepControlsVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONTROLS_IDLE_MS = 5_000;
|
||||||
|
|
||||||
function FullscreenCall({
|
function FullscreenCall({
|
||||||
tiles,
|
tiles,
|
||||||
speaker,
|
speaker,
|
||||||
@@ -774,13 +980,47 @@ function FullscreenCall({
|
|||||||
onFocusTile,
|
onFocusTile,
|
||||||
onTileContextMenu,
|
onTileContextMenu,
|
||||||
controls,
|
controls,
|
||||||
|
keepControlsVisible = false,
|
||||||
}: FullscreenProps) {
|
}: FullscreenProps) {
|
||||||
const [hintGone, setHintGone] = useState(false);
|
const [hintGone, setHintGone] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
// Discord-style auto-hide: controls fade out after 5s of mouse idle in
|
||||||
|
// fullscreen so tiles aren't partially obscured. Any mousemove (or a
|
||||||
|
// popover opening via keepControlsVisible) brings them back immediately.
|
||||||
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
|
// Session-only toggle to collapse the participant strip while watching a
|
||||||
|
// focused tile (screen share, speaker). Matches Discord's "Hide non-video
|
||||||
|
// participants" — gives the focused content the full fullscreen height.
|
||||||
|
const [stripHidden, setStripHidden] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||||
return () => window.clearTimeout(id);
|
return () => window.clearTimeout(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (keepControlsVisible) {
|
||||||
|
setControlsVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let timer: number | null = window.setTimeout(
|
||||||
|
() => setControlsVisible(false),
|
||||||
|
CONTROLS_IDLE_MS,
|
||||||
|
);
|
||||||
|
const reset = () => {
|
||||||
|
setControlsVisible(true);
|
||||||
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(
|
||||||
|
() => setControlsVisible(false),
|
||||||
|
CONTROLS_IDLE_MS,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', reset);
|
||||||
|
window.addEventListener('touchstart', reset);
|
||||||
|
return () => {
|
||||||
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
|
window.removeEventListener('mousemove', reset);
|
||||||
|
window.removeEventListener('touchstart', reset);
|
||||||
|
};
|
||||||
|
}, [keepControlsVisible]);
|
||||||
|
|
||||||
const hasFocus = speaker !== undefined;
|
const hasFocus = speaker !== undefined;
|
||||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||||
@@ -826,7 +1066,7 @@ function FullscreenCall({
|
|||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{others.length > 0 && (
|
{others.length > 0 && !stripHidden && (
|
||||||
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
|
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
|
||||||
{others.map((p) => (
|
{others.map((p) => (
|
||||||
<div
|
<div
|
||||||
@@ -905,13 +1145,76 @@ function FullscreenCall({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
{/* Hide-participant-strip toggle, only meaningful when there's a focus
|
||||||
|
+ extras to hide. Fades alongside the bottom control bar so idle
|
||||||
|
fullscreen still goes clean. */}
|
||||||
|
{hasFocus && others.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStripHidden((v) => !v)}
|
||||||
|
aria-pressed={stripHidden}
|
||||||
|
title={
|
||||||
|
stripHidden
|
||||||
|
? 'Teilnehmer einblenden'
|
||||||
|
: 'Teilnehmer ausblenden'
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
stripHidden
|
||||||
|
? 'Teilnehmer einblenden'
|
||||||
|
: 'Teilnehmer ausblenden'
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
'absolute right-5 top-5 z-20 flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||||
|
(controlsVisible
|
||||||
|
? 'pointer-events-auto opacity-100 '
|
||||||
|
: 'pointer-events-none opacity-0 ') +
|
||||||
|
(stripHidden
|
||||||
|
? 'border-accent bg-accent/20 text-accent-fg'
|
||||||
|
: 'border-white/15 bg-white/10 text-white hover:bg-white/15')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StripToggleIcon hidden={stripHidden} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity duration-200 ' +
|
||||||
|
(controlsVisible
|
||||||
|
? 'pointer-events-auto opacity-100'
|
||||||
|
: 'pointer-events-none opacity-0')
|
||||||
|
}
|
||||||
|
>
|
||||||
{controls}
|
{controls}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Users-icon with a diagonal slash when the strip is hidden — mirrors the
|
||||||
|
// MicOff/HeadphonesOff naming convention used elsewhere.
|
||||||
|
function StripToggleIcon({ hidden }: { hidden: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
{hidden && <line x1="2" y1="2" x2="22" y2="22" />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PttHint() {
|
function PttHint() {
|
||||||
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
||||||
useEffect(() => subscribePttSettings(setPtt), []);
|
useEffect(() => subscribePttSettings(setPtt), []);
|
||||||
@@ -925,3 +1228,65 @@ function PttHint() {
|
|||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-terminal mic error banner. Shown inside the call panel when mic setup
|
||||||
|
// fails — the call itself stays alive, the user just can't be heard. Retry
|
||||||
|
// invokes the pipeline setup again with the current audioSettings so a
|
||||||
|
// permission granted in OS settings mid-call works without rejoin.
|
||||||
|
function MicErrorBanner({
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex items-start gap-3 border-b border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-xs text-rose-700 dark:text-rose-200"
|
||||||
|
>
|
||||||
|
<MicOffIconInline />
|
||||||
|
<p className="flex-1 leading-relaxed">{message}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="cursor-pointer rounded-md bg-rose-600 px-2.5 py-1 text-[11px] font-semibold text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="cursor-pointer rounded-md p-1 text-rose-700/70 transition hover:bg-rose-500/10 hover:text-rose-700 dark:text-rose-200/70 dark:hover:text-rose-100"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny inline variant so we don't pull MicOffIcon's default sizing.
|
||||||
|
function MicOffIconInline() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-0.5 shrink-0"
|
||||||
|
>
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
|
||||||
|
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label="Language"
|
aria-label="Language"
|
||||||
className={
|
className={
|
||||||
'inline-flex items-center rounded-full border border-white/10 bg-white/5 p-0.5 text-[11px] font-medium ' +
|
'inline-flex items-center rounded-full border border-line bg-surface-2 p-0.5 text-[11px] font-medium ' +
|
||||||
(compact ? '' : 'backdrop-blur')
|
(compact ? '' : '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{SUPPORTED_LOCALES.map((locale) => {
|
{SUPPORTED_LOCALES.map((locale) => {
|
||||||
@@ -30,10 +30,10 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
|||||||
if (!active) void changeLocale(locale);
|
if (!active) void changeLocale(locale);
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus:ring-2 focus:ring-brand-400/40 ' +
|
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(active
|
(active
|
||||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
|
||||||
: 'text-neutral-400 hover:text-neutral-200')
|
: 'text-fg-muted hover:text-fg')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{LABELS[locale]}
|
{LABELS[locale]}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MENU_W = 240;
|
const MENU_W = 240;
|
||||||
const MENU_H = 84;
|
const MENU_H = 96;
|
||||||
|
|
||||||
export function ParticipantVolumeMenu({
|
export function ParticipantVolumeMenu({
|
||||||
userId,
|
userId,
|
||||||
@@ -63,14 +63,20 @@ export function ParticipantVolumeMenu({
|
|||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||||
<span className="truncate font-semibold text-fg">{displayName}</span>
|
<span className="truncate font-semibold text-fg">{displayName}</span>
|
||||||
<span className="tabular-nums text-fg-muted">
|
<span
|
||||||
|
className={
|
||||||
|
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
||||||
|
}
|
||||||
|
>
|
||||||
{Math.round(volume * 100)}%
|
{Math.round(volume * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Up to 200% via WebAudio gain. Values above 100% can clip on loud
|
||||||
|
mics — the amber count-up hints at that without a verbose warning. */}
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={2}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -81,6 +87,11 @@ export function ParticipantVolumeMenu({
|
|||||||
aria-label={'Lautstärke ' + displayName}
|
aria-label={'Lautstärke ' + displayName}
|
||||||
className="w-full accent-accent"
|
className="w-full accent-accent"
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-[10px] text-fg-muted">
|
||||||
|
<span>0%</span>
|
||||||
|
<span className="tabular-nums">100%</span>
|
||||||
|
<span>200%</span>
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getParticipantVolume,
|
||||||
|
setParticipantVolume,
|
||||||
|
subscribeParticipantVolumes,
|
||||||
|
} from '../lib/participantVolumes';
|
||||||
|
import {
|
||||||
|
AvatarColorKey,
|
||||||
|
colorKeyFor,
|
||||||
|
} from './CallParticipantTile';
|
||||||
|
import { HeadphonesOffIcon, MicOffIcon, UsersIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
// Rows the popover knows how to render. Subset of InCallPanel's Tile so this
|
||||||
|
// component can be reused without the screen-share / video fields.
|
||||||
|
export interface ParticipantRow {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
self: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
deafened: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
rows: ParticipantRow[];
|
||||||
|
/** Set of userIds currently above the speaking-threshold. */
|
||||||
|
activeSpeakers: Set<string>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVATAR_TONES: Record<AvatarColorKey, string> = {
|
||||||
|
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
|
||||||
|
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
|
||||||
|
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
|
||||||
|
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call-scoped participant list. Portal-mounted + fixed-positioned so it
|
||||||
|
// floats above whichever call layout the user is in (docked, focus, or
|
||||||
|
// fullscreen cinema). Mirrors the ParticipantVolumeMenu pattern for
|
||||||
|
// close-on-outside / close-on-Esc behaviour so both feel consistent.
|
||||||
|
export function ParticipantsPopover({ open, rows, activeSpeakers, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-participants-popover]')) return;
|
||||||
|
// Clicks on the triggering button also bubble here; the button itself
|
||||||
|
// handles toggle, so we only close on genuine outside clicks. The
|
||||||
|
// trigger uses `data-participants-trigger` — ignore those.
|
||||||
|
if (target?.closest('[data-participants-trigger]')) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-participants-popover
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
|
className="fixed bottom-20 right-5 z-[70] flex max-h-[60vh] w-[300px] flex-col overflow-hidden rounded-xl border border-line bg-surface-2/95 shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between gap-2 border-b border-line px-3.5 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
|
||||||
|
<UsersIcon className="h-4 w-4 text-fg-muted" />
|
||||||
|
<span>
|
||||||
|
{t('app:call.participants', { defaultValue: 'Teilnehmer' })} · {rows.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="px-2 py-6 text-center text-xs text-fg-muted">
|
||||||
|
{t('app:call.no_participants', { defaultValue: 'Keine Teilnehmer.' })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<li key={row.userId}>
|
||||||
|
<Row row={row} speaking={activeSpeakers.has(row.userId)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
|
||||||
|
const key = colorKeyFor(row.userId);
|
||||||
|
const tone = AVATAR_TONES[key];
|
||||||
|
const letter = row.displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
const [volume, setVolume] = useState<number>(() =>
|
||||||
|
row.self ? 1 : getParticipantVolume(row.userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (row.self) return;
|
||||||
|
return subscribeParticipantVolumes(() => {
|
||||||
|
setVolume(getParticipantVolume(row.userId));
|
||||||
|
});
|
||||||
|
}, [row.self, row.userId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 rounded-lg px-2 py-1.5 hover:bg-surface-3/60">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
{row.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={row.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold ' +
|
||||||
|
tone
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{speaking && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -inset-0.5 rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
||||||
|
{row.displayName}
|
||||||
|
{row.self && <span className="ml-1 text-fg-muted">(du)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{row.muted && (
|
||||||
|
<span
|
||||||
|
aria-label="Mikro stumm"
|
||||||
|
title="Mikro stumm"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
|
<MicOffIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.deafened && (
|
||||||
|
<span
|
||||||
|
aria-label="Ton aus"
|
||||||
|
title="Ton aus"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
|
<HeadphonesOffIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!row.self && (
|
||||||
|
<div className="flex items-center gap-2 pl-10 text-[11px] text-fg-muted">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setParticipantVolume(row.userId, v);
|
||||||
|
}}
|
||||||
|
aria-label={'Lautstärke ' + row.displayName}
|
||||||
|
className="flex-1 accent-accent"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'w-10 text-right tabular-nums ' +
|
||||||
|
(volume > 1 ? 'text-amber-500' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAudioSettings,
|
||||||
|
subscribeAudioSettings,
|
||||||
|
updateAudioSettings,
|
||||||
|
} from '../lib/audioSettings';
|
||||||
import {
|
import {
|
||||||
clearIncomingRingtone,
|
clearIncomingRingtone,
|
||||||
getIncomingRingtone,
|
getIncomingRingtone,
|
||||||
@@ -30,6 +35,10 @@ export function RingtoneSettings({ disabled = false }: Props) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [volume, setVolume] = useState<number>(() => getAudioSettings().ringtoneVolume);
|
||||||
|
|
||||||
|
// Subscribe so cross-tab / in-call slider moves stay in sync here too.
|
||||||
|
useEffect(() => subscribeAudioSettings((s) => setVolume(s.ringtoneVolume)), []);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -128,7 +137,7 @@ export function RingtoneSettings({ disabled = false }: Props) {
|
|||||||
const url = URL.createObjectURL(current.blob);
|
const url = URL.createObjectURL(current.blob);
|
||||||
const el = new Audio(url);
|
const el = new Audio(url);
|
||||||
el.loop = false;
|
el.loop = false;
|
||||||
el.volume = 0.85;
|
el.volume = volume;
|
||||||
el.onended = () => stopPreview();
|
el.onended = () => stopPreview();
|
||||||
el.onerror = () => {
|
el.onerror = () => {
|
||||||
setError(
|
setError(
|
||||||
@@ -233,6 +242,36 @@ export function RingtoneSettings({ disabled = false }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor="ringtone-volume"
|
||||||
|
className="shrink-0 text-xs font-medium text-fg-muted"
|
||||||
|
>
|
||||||
|
{t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ringtone-volume"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
updateAudioSettings({ ringtoneVolume: v });
|
||||||
|
// Apply to the currently-playing preview so the user hears the
|
||||||
|
// slider effect immediately while dragging.
|
||||||
|
if (previewRef.current) previewRef.current.volume = v;
|
||||||
|
}}
|
||||||
|
aria-label={t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
|
||||||
|
className="flex-1 accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-fg-muted">
|
||||||
|
{Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-fg-muted">
|
<p className="text-[11px] text-fg-muted">
|
||||||
{t('app:settings.ringtone_hint', {
|
{t('app:settings.ringtone_hint', {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useCall } from '../context/CallContext';
|
||||||
|
import {
|
||||||
|
getScreenShareVolume,
|
||||||
|
setScreenShareVolume,
|
||||||
|
subscribeScreenShareVolumes,
|
||||||
|
} from '../lib/screenShareVolumes';
|
||||||
|
import { HeadphonesIcon, HeadphonesOffIcon, MonitorShareIcon, PhoneOffIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Participant whose screen share the user right-clicked. */
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
/** Whether the share has an audio track published. Controls whether the
|
||||||
|
* volume / mute rows render — without audio those would be no-ops. */
|
||||||
|
hasAudio: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_W = 260;
|
||||||
|
const MENU_H_WITH_AUDIO = 200;
|
||||||
|
const MENU_H_NO_AUDIO = 96;
|
||||||
|
|
||||||
|
// Context menu surfaced on right-click of a remote screen share. Mirrors
|
||||||
|
// Discord's stream menu: volume slider, audio mute toggle (independent of
|
||||||
|
// volume — matches HTMLMediaElement's `muted` field), and "stop watching"
|
||||||
|
// which both un-subscribes locally and dismisses the tile from the grid.
|
||||||
|
export function ScreenShareContextMenu({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
hasAudio,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const {
|
||||||
|
dismissShare,
|
||||||
|
screenShareAudioMutedIds,
|
||||||
|
setScreenShareAudioMuted,
|
||||||
|
} = useCall();
|
||||||
|
const [volume, setVolume] = useState<number>(() => getScreenShareVolume(userId));
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribeScreenShareVolumes(() => {
|
||||||
|
setVolume(getScreenShareVolume(userId));
|
||||||
|
}),
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-share-menu]')) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const muted = screenShareAudioMutedIds.has(userId);
|
||||||
|
const height = hasAudio ? MENU_H_WITH_AUDIO : MENU_H_NO_AUDIO;
|
||||||
|
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
|
||||||
|
const top = Math.min(Math.max(8, y), window.innerHeight - height - 8);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-share-menu
|
||||||
|
role="menu"
|
||||||
|
aria-label={t('app:call.share_menu_title', {
|
||||||
|
defaultValue: 'Bildschirmfreigabe von {{name}}',
|
||||||
|
name: displayName,
|
||||||
|
})}
|
||||||
|
style={{ left, top, width: MENU_W }}
|
||||||
|
className="fixed z-[80] overflow-hidden rounded-xl border border-line bg-surface-2/95 text-sm shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<header className="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-fg-muted">
|
||||||
|
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||||
|
<span className="truncate">
|
||||||
|
{t('app:call.share_menu_owner', {
|
||||||
|
defaultValue: 'Bildschirmfreigabe · {{name}}',
|
||||||
|
name: displayName,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{hasAudio && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5 px-3 py-2.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-medium text-fg">
|
||||||
|
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setScreenShareVolume(userId, v);
|
||||||
|
}}
|
||||||
|
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-fg-muted">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
<span>200%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScreenShareAudioMuted(userId, !muted)}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{muted ? (
|
||||||
|
<HeadphonesOffIcon className="h-4 w-4 text-rose-500" />
|
||||||
|
) : (
|
||||||
|
<HeadphonesIcon className="h-4 w-4 text-fg-muted" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1">
|
||||||
|
{muted
|
||||||
|
? t('app:call.share_unmute_audio', { defaultValue: 'Audio einschalten' })
|
||||||
|
: t('app:call.share_mute_audio', { defaultValue: 'Audio stumm' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
dismissShare(userId);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs font-semibold text-rose-600 transition hover:bg-rose-500/10 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
<PhoneOffIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{t('app:call.stop_watching', { defaultValue: 'Zuschauen beenden' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import {
|
|
||||||
type DisplaySurfaceHint,
|
|
||||||
getPresetParams,
|
|
||||||
getScreenShareSettings,
|
|
||||||
PRESET_ORDER,
|
|
||||||
type ScreenSharePreset,
|
|
||||||
updateScreenShareSettings,
|
|
||||||
} from '../lib/screenShareSettings';
|
|
||||||
import {
|
|
||||||
MonitorShareIcon,
|
|
||||||
SpinnerIcon,
|
|
||||||
XIcon,
|
|
||||||
} from './icons';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onStart: (opts: {
|
|
||||||
preset: ScreenSharePreset;
|
|
||||||
displaySurface: DisplaySurfaceHint;
|
|
||||||
framerate: number | null;
|
|
||||||
}) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
|
|
||||||
{ value: null, label: 'Preset-Standard' },
|
|
||||||
{ value: 15, label: '15 fps' },
|
|
||||||
{ value: 30, label: '30 fps' },
|
|
||||||
{ value: 60, label: '60 fps' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Discord-style pre-share dialog. The OS still owns the final source picker
|
|
||||||
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
|
|
||||||
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
|
|
||||||
// in quality + framerate up-front so the user doesn't have to re-open the
|
|
||||||
// system picker to adjust them mid-call.
|
|
||||||
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
|
||||||
const { t } = useTranslation(['app']);
|
|
||||||
const initial = getScreenShareSettings();
|
|
||||||
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
|
|
||||||
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
|
||||||
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
|
|
||||||
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
async function handleStart() {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
// Persist the audio choice alongside the other picker prefs so the
|
|
||||||
// upstream startScreenShare picks it up on its settings read.
|
|
||||||
updateScreenShareSettings({ includeSystemAudio: includeAudio });
|
|
||||||
await onStart({ preset, displaySurface: surface, framerate });
|
|
||||||
onClose();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : 'screenshare failed');
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const presetParams = getPresetParams(preset);
|
|
||||||
const effectiveFps = framerate ?? presetParams.framerate;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
|
||||||
>
|
|
||||||
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
|
||||||
<h3 className="font-display text-sm font-semibold text-fg">
|
|
||||||
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close"
|
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
|
||||||
>
|
|
||||||
<XIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="space-y-5 p-5">
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
|
||||||
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<SurfaceOption
|
|
||||||
active={surface === null}
|
|
||||||
onClick={() => setSurface(null)}
|
|
||||||
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
|
|
||||||
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
|
|
||||||
/>
|
|
||||||
<SurfaceOption
|
|
||||||
active={surface === 'monitor'}
|
|
||||||
onClick={() => setSurface('monitor')}
|
|
||||||
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
|
|
||||||
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
|
|
||||||
/>
|
|
||||||
<SurfaceOption
|
|
||||||
active={surface === 'window'}
|
|
||||||
onClick={() => setSurface('window')}
|
|
||||||
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
|
|
||||||
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
|
||||||
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
|
|
||||||
</p>
|
|
||||||
<select
|
|
||||||
value={preset}
|
|
||||||
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
|
|
||||||
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
|
||||||
>
|
|
||||||
{PRESET_ORDER.map((p) => (
|
|
||||||
<option key={p} value={p}>
|
|
||||||
{getPresetParams(p).label} · ≤ {getPresetParams(p).bitrateKbps} kbps
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
|
||||||
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{FRAMERATE_OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={String(opt.value)}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFramerate(opt.value)}
|
|
||||||
className={
|
|
||||||
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
|
|
||||||
(framerate === opt.value
|
|
||||||
? 'border-accent bg-accent/15 text-accent'
|
|
||||||
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-[11px] text-fg-muted">
|
|
||||||
{t('app:call.share_fps_effective', {
|
|
||||||
defaultValue: 'Effektiv: {{fps}} fps',
|
|
||||||
fps: effectiveFps,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs hover:bg-surface">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={includeAudio}
|
|
||||||
onChange={(e) => setIncludeAudio(e.target.checked)}
|
|
||||||
className="mt-0.5 accent-accent"
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block font-semibold text-fg">
|
|
||||||
{t('app:call.share_system_audio', {
|
|
||||||
defaultValue: 'System-Sound mit übertragen',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block text-[11px] text-fg-muted">
|
|
||||||
{t('app:call.share_system_audio_hint', {
|
|
||||||
defaultValue:
|
|
||||||
'"Go Live" — Systemsound wird mitgesendet. Auf macOS braucht das extra Berechtigungen; wird sonst stumm geteilt.',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-[11px] text-fg-muted">
|
|
||||||
{t('app:call.share_hint', {
|
|
||||||
defaultValue:
|
|
||||||
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleStart()}
|
|
||||||
disabled={busy}
|
|
||||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
|
||||||
<span>
|
|
||||||
{t('app:call.share_start', { defaultValue: 'Teilen starten' })}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SurfaceOption({
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
label,
|
|
||||||
sub,
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
label: string;
|
|
||||||
sub: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={
|
|
||||||
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
|
|
||||||
(active
|
|
||||||
? 'border-accent bg-accent/10 text-fg'
|
|
||||||
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="text-xs font-semibold">{label}</span>
|
|
||||||
<span className="text-[10px] text-fg-muted">{sub}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import type { RemoteTrack } from 'livekit-client';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { RemoteScreenShare } from '../context/CallContext';
|
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||||
import { MonitorShareIcon } from './icons';
|
import { MonitorShareIcon } from './icons';
|
||||||
|
|
||||||
interface ScreenShareViewerProps {
|
interface ScreenShareViewerProps {
|
||||||
@@ -12,12 +12,21 @@ interface ScreenShareViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
||||||
// Lifted out of the old InCallPanel so the new CallDock stays lean.
|
// The watch-state lives in CallContext (not local useState) so it survives
|
||||||
export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
|
// layout-mode changes (grid → focus → fullscreen) without resetting. Same
|
||||||
|
// reason the ScreenShareAudio mute follows this state — see attachTrack.
|
||||||
|
// Right-click handling happens one level up in TileRender — the wrapping
|
||||||
|
// div catches the event before it reaches the viewer's inner content.
|
||||||
|
export function ScreenShareViewer({
|
||||||
|
share,
|
||||||
|
avatarUrl,
|
||||||
|
displayName,
|
||||||
|
}: ScreenShareViewerProps) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [watching, setWatching] = useState(false);
|
const { watchingShareUserIds, watchShare } = useCall();
|
||||||
|
const watching = watchingShareUserIds.has(share.participantId);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
@@ -68,31 +77,15 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{watching && (
|
{watching && (
|
||||||
<>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={toggleFullscreen}
|
||||||
onClick={toggleFullscreen}
|
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
>
|
||||||
>
|
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
</button>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (document.fullscreenElement === containerRef.current) {
|
|
||||||
void document.exitFullscreen();
|
|
||||||
}
|
|
||||||
setWatching(false);
|
|
||||||
}}
|
|
||||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
|
||||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
|
||||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,7 +101,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setWatching(true)}
|
onClick={() => watchShare(share.participantId)}
|
||||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||||
style={{ aspectRatio: '16 / 9' }}
|
style={{ aspectRatio: '16 / 9' }}
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { memo, startTransition, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DisplaySurfaceHint,
|
||||||
|
getPresetParams,
|
||||||
|
getScreenShareSettings,
|
||||||
|
PRESET_ORDER,
|
||||||
|
type ScreenSharePreset,
|
||||||
|
updateScreenShareSettings,
|
||||||
|
} from '../lib/screenShareSettings';
|
||||||
|
import {
|
||||||
|
captureScreenSourceThumbnailBytes,
|
||||||
|
listScreenSources,
|
||||||
|
type ScreenSource,
|
||||||
|
} from '../lib/screenSources';
|
||||||
|
import { MonitorShareIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Parent handles the actual share start. `sourceId` is null when the user
|
||||||
|
* clicks "Teilen" without picking a specific source — fallback to the
|
||||||
|
* OS-level getDisplayMedia picker. */
|
||||||
|
onStart: (opts: {
|
||||||
|
sourceId: string | null;
|
||||||
|
preset: ScreenSharePreset;
|
||||||
|
displaySurface: DisplaySurfaceHint;
|
||||||
|
framerate: number | null;
|
||||||
|
includeAudio: boolean;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord-style picker. Replaces the old form-field dialog with a thumbnail
|
||||||
|
// grid sourced from the Rust `enumerate_screen_sources` command. Clicking a
|
||||||
|
// thumbnail stashes its Chromium-format id; the parent then attempts a
|
||||||
|
// `chromeMediaSourceId`-constrained getUserMedia call. If WebView2 ignores
|
||||||
|
// the constraint (it may), the fallback OS picker still runs — but at least
|
||||||
|
// the user already saw + chose from a real preview first.
|
||||||
|
export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const initial = getScreenShareSettings();
|
||||||
|
const [sources, setSources] = useState<ScreenSource[] | null>(null);
|
||||||
|
// Thumbnails are kept in a separate state from the source list so an
|
||||||
|
// arriving thumbnail never creates a new `ScreenSource` object for
|
||||||
|
// unrelated cards — memo compares `thumbnailUrl` by string identity,
|
||||||
|
// so only the one card whose URL changes rerenders.
|
||||||
|
const [thumbnailUrls, setThumbnailUrls] = useState<Record<string, string>>({});
|
||||||
|
// All blob URLs we've handed out this session. Revoked on picker close
|
||||||
|
// so the native buffers they point at don't leak across opens.
|
||||||
|
const blobUrlsRef = useRef<string[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
||||||
|
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Two-phase load: (1) fast list returns names + placeholders so the grid
|
||||||
|
// paints instantly, (2) capture thumbnails in a bounded worker-pool
|
||||||
|
// using the binary-IPC variant. Arriving bytes are wrapped in a Blob
|
||||||
|
// and exposed via URL.createObjectURL — no base64 on either side,
|
||||||
|
// which is the single biggest main-thread win compared to the old
|
||||||
|
// JSON-of-base64 flow. Combined with rAF-batched state updates, the
|
||||||
|
// picker stays responsive even on 20+ source enumerations.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
// Revoke blob URLs created during the last session so native
|
||||||
|
// buffers don't linger after close.
|
||||||
|
for (const url of blobUrlsRef.current) URL.revokeObjectURL(url);
|
||||||
|
blobUrlsRef.current = [];
|
||||||
|
setSources(null);
|
||||||
|
setThumbnailUrls({});
|
||||||
|
setSelectedId(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
// Coalesce thumbnail arrivals within a single animation frame into
|
||||||
|
// one setState — cuts re-renders from O(N) to O(frames) during the
|
||||||
|
// initial fan-in and prevents consecutive 10-30 ms long tasks from
|
||||||
|
// stacking in one frame.
|
||||||
|
//
|
||||||
|
// Previously `batch` was aliased to `pendingUrls` and then we cleared
|
||||||
|
// pendingUrls via `delete` — which emptied batch too (same reference)
|
||||||
|
// and every flush ended up spreading nothing into the state. Clone
|
||||||
|
// first, then clear, so the batch keeps its entries.
|
||||||
|
let pendingUrls: Record<string, string> = {};
|
||||||
|
let rafScheduled = false;
|
||||||
|
const flush = () => {
|
||||||
|
rafScheduled = false;
|
||||||
|
const batch = pendingUrls;
|
||||||
|
if (Object.keys(batch).length === 0) return;
|
||||||
|
pendingUrls = {};
|
||||||
|
startTransition(() => {
|
||||||
|
setThumbnailUrls((prev) => ({ ...prev, ...batch }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const queueUrl = (id: string, url: string) => {
|
||||||
|
pendingUrls[id] = url;
|
||||||
|
if (!rafScheduled) {
|
||||||
|
rafScheduled = true;
|
||||||
|
requestAnimationFrame(flush);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Concurrency 2: Windows GDI BitBlt / PrintWindow contends for the
|
||||||
|
// desktop compositor, so 4+ parallel captures stutter the whole Tauri
|
||||||
|
// window. 2 in parallel keeps the compositor breathing.
|
||||||
|
const CONCURRENCY = 2;
|
||||||
|
void (async () => {
|
||||||
|
const list = await listScreenSources();
|
||||||
|
if (cancelled) return;
|
||||||
|
setSources(list);
|
||||||
|
const queue = [...list];
|
||||||
|
const pickOne = (src: typeof list[number]) => {
|
||||||
|
void (async () => {
|
||||||
|
const blob = await captureScreenSourceThumbnailBytes(src.id);
|
||||||
|
if (cancelled) {
|
||||||
|
// Edge case: picker closed while this request was in flight.
|
||||||
|
// blob may still exist; nothing holds a URL to it, so it GCs.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (blob) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
blobUrlsRef.current.push(url);
|
||||||
|
queueUrl(src.id, url);
|
||||||
|
}
|
||||||
|
const nextSrc = queue.shift();
|
||||||
|
if (nextSrc) pickOne(nextSrc);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
|
||||||
|
const s = queue.shift();
|
||||||
|
if (s) pickOne(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const screens = sources?.filter((s) => s.kind === 'screen') ?? [];
|
||||||
|
const windows = sources?.filter((s) => s.kind === 'window') ?? [];
|
||||||
|
const hasAny = (sources?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
async function handleStart(): Promise<void> {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Persist user's audio + preset choice so subsequent shares start with
|
||||||
|
// the same prefs when they skip the picker. The picker itself stays
|
||||||
|
// as the entry for future starts (right-click on share button also
|
||||||
|
// opens it — see InCallPanel wiring).
|
||||||
|
updateScreenShareSettings({ preset, includeSystemAudio: includeAudio });
|
||||||
|
// Infer a displaySurface hint from the selection so the fallback OS
|
||||||
|
// picker jumps to the right tab when our direct-publish path is
|
||||||
|
// rejected by WebView2.
|
||||||
|
const selected = sources?.find((s) => s.id === selectedId) ?? null;
|
||||||
|
const hint: DisplaySurfaceHint =
|
||||||
|
selected?.kind === 'screen'
|
||||||
|
? 'monitor'
|
||||||
|
: selected?.kind === 'window'
|
||||||
|
? 'window'
|
||||||
|
: null;
|
||||||
|
await onStart({
|
||||||
|
sourceId: selected?.id ?? null,
|
||||||
|
preset,
|
||||||
|
displaySurface: hint,
|
||||||
|
framerate: null,
|
||||||
|
includeAudio,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'screenshare failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex max-h-[88vh] w-full max-w-[860px] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{sources === null ? (
|
||||||
|
<div className="flex h-40 items-center justify-center gap-2 text-sm text-fg-muted">
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{t('app:call.share_loading', { defaultValue: 'Lade Quellen…' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : !hasAny ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 px-6 py-10 text-center text-sm text-fg-muted">
|
||||||
|
<MonitorShareIcon className="h-6 w-6 opacity-60" />
|
||||||
|
<span>
|
||||||
|
{t('app:call.share_no_sources', {
|
||||||
|
defaultValue:
|
||||||
|
'Keine Quellen-Previews verfügbar. Klick auf „Teilen" öffnet den System-Picker.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5 p-5">
|
||||||
|
{screens.length > 0 && (
|
||||||
|
<SourceSection
|
||||||
|
title={t('app:call.share_screens', { defaultValue: 'Bildschirme' })}
|
||||||
|
sources={screens}
|
||||||
|
thumbnailUrls={thumbnailUrls}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{windows.length > 0 && (
|
||||||
|
<SourceSection
|
||||||
|
title={t('app:call.share_windows', { defaultValue: 'Fenster' })}
|
||||||
|
sources={windows}
|
||||||
|
thumbnailUrls={thumbnailUrls}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex flex-col gap-3 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg">
|
||||||
|
<span className="font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={preset}
|
||||||
|
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
|
||||||
|
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-xs text-fg focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{PRESET_ORDER.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{getPresetParams(p).label} · ≤ {getPresetParams(p).bitrateKbps} kbps
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeAudio}
|
||||||
|
onChange={(e) => setIncludeAudio(e.target.checked)}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('app:call.share_system_audio', {
|
||||||
|
defaultValue: 'System-Sound mit übertragen',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-rose-600 dark:text-rose-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleStart()}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||||
|
<span>
|
||||||
|
{selectedId
|
||||||
|
? t('app:call.share_start', { defaultValue: 'Teilen' })
|
||||||
|
: t('app:call.share_pick_system', {
|
||||||
|
defaultValue: 'Ohne Auswahl weiter',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceSection({
|
||||||
|
title,
|
||||||
|
sources,
|
||||||
|
thumbnailUrls,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
sources: ScreenSource[];
|
||||||
|
thumbnailUrls: Record<string, string>;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
|
||||||
|
{sources.map((src) => (
|
||||||
|
<SourceCard
|
||||||
|
key={src.id}
|
||||||
|
source={src}
|
||||||
|
thumbnailUrl={thumbnailUrls[src.id] ?? null}
|
||||||
|
selected={selectedId === src.id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoized so a thumbnail arriving for card B doesn't re-render card A.
|
||||||
|
// Keeps re-render work proportional to the number of updates instead of
|
||||||
|
// "whole grid on every update" — which was the main reason scrolling felt
|
||||||
|
// frozen during the initial thumbnail fan-in.
|
||||||
|
//
|
||||||
|
// The parent passes `onSelect(id)` rather than an inline `onClick`-arrow
|
||||||
|
// so the callback reference stays stable across renders; otherwise
|
||||||
|
// React.memo would always see a fresh function prop and re-render every
|
||||||
|
// card on every parent update.
|
||||||
|
const SourceCard = memo(function SourceCard({
|
||||||
|
source,
|
||||||
|
thumbnailUrl,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
source: ScreenSource;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(source.id)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
title={source.name}
|
||||||
|
className={
|
||||||
|
'group flex cursor-pointer flex-col overflow-hidden rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||||
|
(selected
|
||||||
|
? 'border-accent ring-2 ring-accent/30'
|
||||||
|
: 'border-line hover:border-accent/70')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video w-full overflow-hidden bg-black">
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
// decoding="async" keeps image decode off the main-thread paint
|
||||||
|
// step; loading="lazy" means cards outside the viewport don't
|
||||||
|
// ask the browser to decode until the user scrolls to them. The
|
||||||
|
// URL is a blob: URL backed by the ArrayBuffer Rust sent over
|
||||||
|
// IPC — no base64 decode, no data-URL parse, just direct bytes
|
||||||
|
// into the decoder.
|
||||||
|
<img
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-contain transition group-hover:brightness-110"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-surface-2 to-surface-3 text-fg-muted">
|
||||||
|
<MonitorShareIcon className="h-6 w-6 opacity-50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate px-2.5 py-1.5 text-left text-xs font-medium text-fg">
|
||||||
|
{source.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,12 @@ export interface AudioSettings {
|
|||||||
// @livekit/track-processors + its MediaPipe selfie-segmentation model
|
// @livekit/track-processors + its MediaPipe selfie-segmentation model
|
||||||
// (~1.5MB) which downloads on first activation.
|
// (~1.5MB) which downloads on first activation.
|
||||||
videoBackgroundBlur: boolean;
|
videoBackgroundBlur: boolean;
|
||||||
|
// Ringtone volume for both the generated oscillator fallback and the
|
||||||
|
// custom incoming-call audio file. 0..1; applied on top of the base
|
||||||
|
// oscillator gain so the fallback stays audible at 100% without being
|
||||||
|
// harsh at 25%. Separate from any system / call audio volume so users
|
||||||
|
// can have loud rings + soft in-call audio.
|
||||||
|
ringtoneVolume: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: AudioSettings = {
|
const DEFAULTS: AudioSettings = {
|
||||||
@@ -35,8 +41,12 @@ const DEFAULTS: AudioSettings = {
|
|||||||
inputDeviceId: null,
|
inputDeviceId: null,
|
||||||
outputDeviceId: null,
|
outputDeviceId: null,
|
||||||
voiceThreshold: 0.03,
|
voiceThreshold: 0.03,
|
||||||
noiseSuppression: true,
|
// Off by default — browser-native NS colours voice audibly on some
|
||||||
|
// mics and is a frequent "why does my voice sound weird" report.
|
||||||
|
// Users who want it enable it explicitly in Settings → Sprache.
|
||||||
|
noiseSuppression: false,
|
||||||
videoBackgroundBlur: false,
|
videoBackgroundBlur: false,
|
||||||
|
ringtoneVolume: 0.9,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AudioQualityParams {
|
export interface AudioQualityParams {
|
||||||
@@ -118,6 +128,13 @@ function read(): AudioSettings {
|
|||||||
typeof parsed.videoBackgroundBlur === 'boolean'
|
typeof parsed.videoBackgroundBlur === 'boolean'
|
||||||
? parsed.videoBackgroundBlur
|
? parsed.videoBackgroundBlur
|
||||||
: DEFAULTS.videoBackgroundBlur,
|
: DEFAULTS.videoBackgroundBlur,
|
||||||
|
ringtoneVolume:
|
||||||
|
typeof parsed.ringtoneVolume === 'number' &&
|
||||||
|
Number.isFinite(parsed.ringtoneVolume) &&
|
||||||
|
parsed.ringtoneVolume >= 0 &&
|
||||||
|
parsed.ringtoneVolume <= 1
|
||||||
|
? parsed.ringtoneVolume
|
||||||
|
: DEFAULTS.ringtoneVolume,
|
||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -48,6 +48,38 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Press-only global shortcut (for toggles like Mute/Deafen). Accepts an
|
||||||
|
// already-formatted accelerator string (e.g. "CommandOrControl+Shift+M")
|
||||||
|
// since these bindings may include modifier chords — the KeyboardEvent.code
|
||||||
|
// variant used by PTT can't express that.
|
||||||
|
export async function registerGlobalShortcutPress(
|
||||||
|
shortcut: string,
|
||||||
|
onPress: () => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (await isRegistered(shortcut)) {
|
||||||
|
await unregister(shortcut);
|
||||||
|
}
|
||||||
|
await register(shortcut, (event: ShortcutEvent) => {
|
||||||
|
if (event.state === 'Pressed') onPress();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('registerGlobalShortcutPress failed', { shortcut, err });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterGlobalShortcut(shortcut: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (await isRegistered(shortcut)) {
|
||||||
|
await unregister(shortcut);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('unregisterGlobalShortcut failed', { shortcut, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detects whether we're running under Tauri. When running in a pure web
|
// Detects whether we're running under Tauri. When running in a pure web
|
||||||
// preview (vite dev in a browser without Tauri), importing the plugin still
|
// preview (vite dev in a browser without Tauri), importing the plugin still
|
||||||
// works but calls fall through to window.__TAURI_INTERNALS__ which doesn't
|
// works but calls fall through to window.__TAURI_INTERNALS__ which doesn't
|
||||||
|
|||||||
@@ -30,9 +30,14 @@ function load(): VolumeMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches Discord's slider range — up to 200% via a WebAudio GainNode in
|
||||||
|
// remoteAudioPipelines (HTMLMediaElement.volume caps at 1.0 on its own).
|
||||||
|
const MAX_VOLUME = 2;
|
||||||
|
|
||||||
function clamp(v: number): number {
|
function clamp(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 0;
|
||||||
if (v < 0) return 0;
|
if (v < 0) return 0;
|
||||||
if (v > 1) return 1;
|
if (v > MAX_VOLUME) return MAX_VOLUME;
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +78,18 @@ export function subscribeParticipantVolumes(fn: Listener): () => void {
|
|||||||
|
|
||||||
// Apply a volume to any audio elements already attached for this user.
|
// Apply a volume to any audio elements already attached for this user.
|
||||||
// Attached elements are tagged with `data-participant` in attachTrack.
|
// Attached elements are tagged with `data-participant` in attachTrack.
|
||||||
|
// HTMLMediaElement.volume is hard-clamped to [0, 1] — anything above 1
|
||||||
|
// throws IndexSizeError. Values above 1 are only meaningful on the
|
||||||
|
// WebAudio path (remoteAudioPipelines' GainNode handles them); on the
|
||||||
|
// plain-element fallback path we clip at 1.0 so the user just hears the
|
||||||
|
// loudest level the element supports rather than an exception.
|
||||||
function applyToAttachedElements(userId: string, volume: number): void {
|
function applyToAttachedElements(userId: string, volume: number): void {
|
||||||
|
const elVolume = Math.min(1, Math.max(0, volume));
|
||||||
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
||||||
'audio[data-participant="' + cssEscape(userId) + '"]',
|
'audio[data-participant="' + cssEscape(userId) + '"]',
|
||||||
);
|
);
|
||||||
nodes.forEach((el) => {
|
nodes.forEach((el) => {
|
||||||
el.volume = volume;
|
el.volume = elVolume;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Central registry of Web-Audio pipelines for every remote audio track we're
|
||||||
|
// playing. Exists because `HTMLMediaElement.volume` caps at 1.0 — to let a
|
||||||
|
// user boost a quiet peer past 100% we need an explicit GainNode in the
|
||||||
|
// output chain. Once a track is on the WebAudio path, `audio.muted` / volume
|
||||||
|
// no longer drive output (the MediaElementSource diverts samples through the
|
||||||
|
// graph), so all gating — deafen, watch-state, manual mute, per-user volume —
|
||||||
|
// is collapsed into a single effective-gain value per pipeline.
|
||||||
|
//
|
||||||
|
// The registry is a plain module-level Map. Attach/detach lifecycle is owned
|
||||||
|
// by CallContext's attachTrack/detachTrack helpers; gain recomputation is
|
||||||
|
// also triggered from CallContext when any state the formula depends on
|
||||||
|
// changes.
|
||||||
|
|
||||||
|
export type RemoteTrackSource = 'microphone' | 'screenshare';
|
||||||
|
|
||||||
|
export interface RemoteAudioPipeline {
|
||||||
|
/** LiveKit track sid — stable identifier for this track's lifetime. */
|
||||||
|
trackSid: string;
|
||||||
|
participantId: string;
|
||||||
|
trackSource: RemoteTrackSource;
|
||||||
|
audio: HTMLAudioElement;
|
||||||
|
ctx: AudioContext;
|
||||||
|
source: MediaElementAudioSourceNode;
|
||||||
|
gain: GainNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipelines = new Map<string, RemoteAudioPipeline>();
|
||||||
|
|
||||||
|
// Build the WebAudio chain for an already-attached HTMLAudioElement. Returns
|
||||||
|
// null when WebAudio isn't available (older browsers) — callers should fall
|
||||||
|
// back to `audio.volume` in that case.
|
||||||
|
export function createPipeline(
|
||||||
|
audio: HTMLAudioElement,
|
||||||
|
info: { trackSid: string; participantId: string; trackSource: RemoteTrackSource },
|
||||||
|
): RemoteAudioPipeline | null {
|
||||||
|
const AudioCtx =
|
||||||
|
window.AudioContext ??
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!AudioCtx) return null;
|
||||||
|
try {
|
||||||
|
const ctx = new AudioCtx();
|
||||||
|
const source = ctx.createMediaElementSource(audio);
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
// Start silent; the caller (CallContext) applies the correct effective
|
||||||
|
// gain immediately after registering via `setPipelineGain`.
|
||||||
|
gain.gain.value = 0;
|
||||||
|
source.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
// createMediaElementSource diverts the element's direct output through
|
||||||
|
// the audio graph. Muting the element is then a double-guard — if the
|
||||||
|
// diversion ever fails (older WebKit), the element stays silent instead
|
||||||
|
// of bypassing the gain chain entirely.
|
||||||
|
audio.muted = true;
|
||||||
|
const pipeline: RemoteAudioPipeline = {
|
||||||
|
trackSid: info.trackSid,
|
||||||
|
participantId: info.participantId,
|
||||||
|
trackSource: info.trackSource,
|
||||||
|
audio,
|
||||||
|
ctx,
|
||||||
|
source,
|
||||||
|
gain,
|
||||||
|
};
|
||||||
|
pipelines.set(info.trackSid, pipeline);
|
||||||
|
return pipeline;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('createPipeline failed', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyPipeline(trackSid: string): void {
|
||||||
|
const p = pipelines.get(trackSid);
|
||||||
|
if (!p) return;
|
||||||
|
pipelines.delete(trackSid);
|
||||||
|
try {
|
||||||
|
p.source.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
p.gain.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
void p.ctx.close().catch(() => {
|
||||||
|
/* ignore */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allPipelines(): IterableIterator<RemoteAudioPipeline> {
|
||||||
|
return pipelines.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPipeline(trackSid: string): RemoteAudioPipeline | undefined {
|
||||||
|
return pipelines.get(trackSid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pipelinesFor(
|
||||||
|
participantId: string,
|
||||||
|
source?: RemoteTrackSource,
|
||||||
|
): RemoteAudioPipeline[] {
|
||||||
|
const out: RemoteAudioPipeline[] = [];
|
||||||
|
for (const p of pipelines.values()) {
|
||||||
|
if (p.participantId !== participantId) continue;
|
||||||
|
if (source && p.trackSource !== source) continue;
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ramp the gain slightly (~20ms) so 0→2x doesn't introduce a click and so
|
||||||
|
// rapid slider drags stay smooth. ctx.currentTime is the right anchor —
|
||||||
|
// setValueAtTime jumps abruptly.
|
||||||
|
export function setPipelineGain(pipeline: RemoteAudioPipeline, value: number): void {
|
||||||
|
const v = clampGain(value);
|
||||||
|
try {
|
||||||
|
pipeline.gain.gain.setTargetAtTime(v, pipeline.ctx.currentTime, 0.02);
|
||||||
|
} catch {
|
||||||
|
// Some contexts in a closed state throw — just direct-assign as a
|
||||||
|
// fallback; worst case the next valid call smooths it out.
|
||||||
|
pipeline.gain.gain.value = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampGain(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 0;
|
||||||
|
if (v < 0) return 0;
|
||||||
|
// 2.0 matches Discord's upper bound (200%). Going higher invites clipping
|
||||||
|
// since the source is already peaking at 1.0 for most mics.
|
||||||
|
if (v > 2) return 2;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route every pipeline's AudioContext to the given sink. Feature-detects
|
||||||
|
// `AudioContext.setSinkId` (Chrome 115+); older runtimes silently keep the
|
||||||
|
// default sink, which is the behaviour we had before the WebAudio refactor
|
||||||
|
// so this is a strict improvement rather than a regression.
|
||||||
|
export async function setAllPipelinesSinkId(deviceId: string): Promise<void> {
|
||||||
|
const maybeId = deviceId.length === 0 ? 'default' : deviceId;
|
||||||
|
for (const p of pipelines.values()) {
|
||||||
|
const ctxAny = p.ctx as unknown as {
|
||||||
|
setSinkId?: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
if (typeof ctxAny.setSinkId !== 'function') continue;
|
||||||
|
try {
|
||||||
|
await ctxAny.setSinkId(maybeId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('AudioContext.setSinkId failed', { trackSid: p.trackSid, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
// back to the generated oscillator pattern so ringing never misses an
|
// back to the generated oscillator pattern so ringing never misses an
|
||||||
// incoming call due to an IO failure.
|
// incoming call due to an IO failure.
|
||||||
|
|
||||||
|
import { getAudioSettings, subscribeAudioSettings } from './audioSettings';
|
||||||
import { getIncomingRingtone } from './ringtoneStorage';
|
import { getIncomingRingtone } from './ringtoneStorage';
|
||||||
|
|
||||||
type Pattern = 'outgoing' | 'incoming';
|
type Pattern = 'outgoing' | 'incoming';
|
||||||
@@ -21,6 +22,15 @@ class Ringtone {
|
|||||||
private customUrl: string | null = null;
|
private customUrl: string | null = null;
|
||||||
// Sequence token to ignore slow IO completing after user changed state.
|
// Sequence token to ignore slow IO completing after user changed state.
|
||||||
private startSeq = 0;
|
private startSeq = 0;
|
||||||
|
// Live-subscribe so settings-slider changes reflect while the ringtone
|
||||||
|
// is playing (user can hear the effect of their slider immediately).
|
||||||
|
private unsubVolume: (() => void) | null = null;
|
||||||
|
|
||||||
|
private get volume(): number {
|
||||||
|
const v = getAudioSettings().ringtoneVolume;
|
||||||
|
if (!Number.isFinite(v)) return 0.9;
|
||||||
|
return Math.min(1, Math.max(0, v));
|
||||||
|
}
|
||||||
|
|
||||||
start(pattern: Pattern): void {
|
start(pattern: Pattern): void {
|
||||||
if (this.pattern === pattern) return; // already playing this pattern
|
if (this.pattern === pattern) return; // already playing this pattern
|
||||||
@@ -28,6 +38,12 @@ class Ringtone {
|
|||||||
this.pattern = pattern;
|
this.pattern = pattern;
|
||||||
const seq = ++this.startSeq;
|
const seq = ++this.startSeq;
|
||||||
|
|
||||||
|
// Track live slider moves so the user can dial in the volume while a
|
||||||
|
// call is ringing and hear the change immediately.
|
||||||
|
this.unsubVolume = subscribeAudioSettings(() => {
|
||||||
|
if (this.audioEl) this.audioEl.volume = this.volume;
|
||||||
|
});
|
||||||
|
|
||||||
if (pattern === 'incoming') {
|
if (pattern === 'incoming') {
|
||||||
// Kick off oscillator immediately so we never miss ringing feedback
|
// Kick off oscillator immediately so we never miss ringing feedback
|
||||||
// while the custom file (if any) loads asynchronously. Once the blob
|
// while the custom file (if any) loads asynchronously. Once the blob
|
||||||
@@ -44,6 +60,10 @@ class Ringtone {
|
|||||||
this.stopOscillator();
|
this.stopOscillator();
|
||||||
this.stopCustom();
|
this.stopCustom();
|
||||||
this.pattern = null;
|
this.pattern = null;
|
||||||
|
if (this.unsubVolume) {
|
||||||
|
this.unsubVolume();
|
||||||
|
this.unsubVolume = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Custom file path (incoming only) ----------------------------------
|
// --- Custom file path (incoming only) ----------------------------------
|
||||||
@@ -61,7 +81,7 @@ class Ringtone {
|
|||||||
const url = URL.createObjectURL(stored.blob);
|
const url = URL.createObjectURL(stored.blob);
|
||||||
const el = new Audio(url);
|
const el = new Audio(url);
|
||||||
el.loop = true;
|
el.loop = true;
|
||||||
el.volume = 0.85;
|
el.volume = this.volume;
|
||||||
// Chrome/WKWebView autoplay policy: muted playback is always allowed,
|
// Chrome/WKWebView autoplay policy: muted playback is always allowed,
|
||||||
// but ringtones must be audible, so play() may reject the first time
|
// but ringtones must be audible, so play() may reject the first time
|
||||||
// before the user interacted. If it rejects, we keep the oscillator.
|
// before the user interacted. If it rejects, we keep the oscillator.
|
||||||
@@ -132,25 +152,31 @@ class Ringtone {
|
|||||||
osc.connect(g);
|
osc.connect(g);
|
||||||
g.connect(ctx.destination);
|
g.connect(ctx.destination);
|
||||||
const t0 = ctx.currentTime + delaySec;
|
const t0 = ctx.currentTime + delaySec;
|
||||||
|
// Volume slider multiplies the base gain so the fallback tone tracks
|
||||||
|
// the user's preference. A flat user-setting of 0 keeps the pattern
|
||||||
|
// running visually (oscillator nodes alive) but inaudible.
|
||||||
|
const effectiveGain = gain * this.volume;
|
||||||
g.gain.setValueAtTime(0, t0);
|
g.gain.setValueAtTime(0, t0);
|
||||||
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
|
g.gain.linearRampToValueAtTime(effectiveGain, t0 + 0.02);
|
||||||
g.gain.exponentialRampToValueAtTime(0.001, t0 + durationSec);
|
g.gain.exponentialRampToValueAtTime(0.001, t0 + durationSec);
|
||||||
osc.start(t0);
|
osc.start(t0);
|
||||||
osc.stop(t0 + durationSec + 0.02);
|
osc.stop(t0 + durationSec + 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
private playOutgoing(): void {
|
private playOutgoing(): void {
|
||||||
// Soft calling tone — single warm note.
|
// Soft calling tone — single warm note. Slightly bumped from 0.14 so
|
||||||
this.beep(440, 0.4, 0, 0.14);
|
// it's audible on laptop speakers without blasting.
|
||||||
this.beep(440, 0.4, 0.6, 0.14);
|
this.beep(440, 0.4, 0, 0.22);
|
||||||
|
this.beep(440, 0.4, 0.6, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
private playIncoming(): void {
|
private playIncoming(): void {
|
||||||
// Classic double-ring "ring ring".
|
// Classic double-ring "ring ring". Bumped from 0.22 → 0.4 so it's
|
||||||
this.beep(880, 0.18, 0, 0.22);
|
// unmissable through music / background noise.
|
||||||
this.beep(660, 0.18, 0.22, 0.22);
|
this.beep(880, 0.18, 0, 0.4);
|
||||||
this.beep(880, 0.18, 0.6, 0.22);
|
this.beep(660, 0.18, 0.22, 0.4);
|
||||||
this.beep(660, 0.18, 0.82, 0.22);
|
this.beep(880, 0.18, 0.6, 0.4);
|
||||||
|
this.beep(660, 0.18, 0.82, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Frontend side of the native system-audio pipeline. Pairs with the Rust
|
||||||
|
// `screen_audio` module: it opens a Tauri Channel, receives interleaved
|
||||||
|
// f32 stereo samples at 48kHz (base64-encoded), and surfaces them as a
|
||||||
|
// real `MediaStream` that LiveKit can publish as a `ScreenShareAudio`
|
||||||
|
// track. An AudioWorklet does the heavy lifting so the render thread is
|
||||||
|
// never the bottleneck — the main thread just pushes decoded samples
|
||||||
|
// across a port; the worklet copies them into its output buffer which
|
||||||
|
// feeds a `MediaStreamDestination`.
|
||||||
|
//
|
||||||
|
// Windows-only right now. On other platforms `startSystemAudioCapture`
|
||||||
|
// throws `SystemAudioUnavailable` and the caller is expected to fall
|
||||||
|
// back to the browser's getDisplayMedia path.
|
||||||
|
|
||||||
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
|
||||||
|
export interface SystemAudioHandle {
|
||||||
|
/** Rust-side capture id. Pass to the Rust stop command via `stop()`. */
|
||||||
|
captureId: number;
|
||||||
|
/** MediaStream carrying a single audio track at 48kHz stereo. */
|
||||||
|
stream: MediaStream;
|
||||||
|
/** Teardown — stops the Rust thread, closes the AudioContext, ends the
|
||||||
|
* MediaStreamDestination track. Idempotent. */
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when the platform can't deliver native system-audio (non-Tauri
|
||||||
|
* runtime, non-Windows host, WebAudio unavailable, COM init failure). */
|
||||||
|
export class SystemAudioUnavailable extends Error {
|
||||||
|
constructor(reason: string) {
|
||||||
|
super('system audio unavailable: ' + reason);
|
||||||
|
this.name = 'SystemAudioUnavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioFramePayload {
|
||||||
|
captureId: number;
|
||||||
|
sampleRate: number;
|
||||||
|
channels: number;
|
||||||
|
samplesBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioWorklet source embedded as a string. The worklet keeps a pair of
|
||||||
|
// ring buffers (one per channel) that the main thread appends to as
|
||||||
|
// samples arrive. `process()` drains the ring buffers into the output
|
||||||
|
// blocks; an underrun emits silence instead of propagating the stall
|
||||||
|
// upwards (a glitch is better than a freeze for LiveKit's Opus encoder).
|
||||||
|
//
|
||||||
|
// The worklet runs at AudioContext sample rate, which we pin to 48kHz via
|
||||||
|
// the AudioContext constructor. That matches what the Rust side already
|
||||||
|
// resamples to, so no further rate conversion is needed here.
|
||||||
|
const WORKLET_SOURCE = `
|
||||||
|
class LoopbackAudioProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
// Ring buffer sized for latency, not for "never drop". 300ms hard cap,
|
||||||
|
// 80ms target — we aim for ~one WASAPI packet of headroom above the
|
||||||
|
// render quantum and drop excess whenever the producer gets ahead.
|
||||||
|
// Keeping the target small is the difference between "feels live" and
|
||||||
|
// "laggy" for screen-share audio.
|
||||||
|
this.bufferSize = 48000 * 0.3 | 0;
|
||||||
|
this.targetFrames = 48000 * 0.08 | 0;
|
||||||
|
this.bufL = new Float32Array(this.bufferSize);
|
||||||
|
this.bufR = new Float32Array(this.bufferSize);
|
||||||
|
this.writePos = 0;
|
||||||
|
this.readPos = 0;
|
||||||
|
this.available = 0;
|
||||||
|
this.port.onmessage = (e) => {
|
||||||
|
const { left, right } = e.data;
|
||||||
|
const len = left.length;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
this.bufL[this.writePos] = left[i];
|
||||||
|
this.bufR[this.writePos] = right[i];
|
||||||
|
this.writePos = (this.writePos + 1) % this.bufferSize;
|
||||||
|
if (this.available < this.bufferSize) {
|
||||||
|
this.available++;
|
||||||
|
} else {
|
||||||
|
// Buffer full — advance the read cursor to keep writing.
|
||||||
|
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hard cap: if we're this far behind the producer, skip ahead to
|
||||||
|
// the target latency instead of playing out minutes of stale audio.
|
||||||
|
// Happens on: AudioContext resume after suspend, tab throttle
|
||||||
|
// recovery, any hiccup that left samples piling up.
|
||||||
|
if (this.available > this.targetFrames * 3) {
|
||||||
|
const drop = this.available - this.targetFrames;
|
||||||
|
this.readPos = (this.readPos + drop) % this.bufferSize;
|
||||||
|
this.available -= drop;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
process(_inputs, outputs) {
|
||||||
|
const output = outputs[0];
|
||||||
|
if (!output || output.length === 0) return true;
|
||||||
|
const out0 = output[0];
|
||||||
|
const out1 = output[1] || output[0];
|
||||||
|
const n = out0.length;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (this.available > 0) {
|
||||||
|
out0[i] = this.bufL[this.readPos];
|
||||||
|
if (out1 !== out0) out1[i] = this.bufR[this.readPos];
|
||||||
|
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||||
|
this.available--;
|
||||||
|
} else {
|
||||||
|
out0[i] = 0;
|
||||||
|
if (out1 !== out0) out1[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerProcessor('screen-audio-loopback', LoopbackAudioProcessor);
|
||||||
|
`;
|
||||||
|
|
||||||
|
let workletModuleUrl: string | null = null;
|
||||||
|
function getWorkletModuleUrl(): string {
|
||||||
|
if (workletModuleUrl) return workletModuleUrl;
|
||||||
|
const blob = new Blob([WORKLET_SOURCE], { type: 'application/javascript' });
|
||||||
|
workletModuleUrl = URL.createObjectURL(blob);
|
||||||
|
return workletModuleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSystemAudioCapture(): Promise<SystemAudioHandle> {
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
throw new SystemAudioUnavailable('not a tauri runtime');
|
||||||
|
}
|
||||||
|
const AudioCtor: typeof AudioContext | undefined =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? (window.AudioContext ??
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||||
|
.webkitAudioContext)
|
||||||
|
: undefined;
|
||||||
|
if (!AudioCtor) {
|
||||||
|
throw new SystemAudioUnavailable('WebAudio unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin to 48kHz so the worklet's input rate matches the Rust-side
|
||||||
|
// output rate. If the OS forces a different rate the constructor
|
||||||
|
// throws on some browsers; we catch and surface as Unavailable so the
|
||||||
|
// caller can fall back.
|
||||||
|
let ctx: AudioContext;
|
||||||
|
try {
|
||||||
|
ctx = new AudioCtor({ sampleRate: 48000, latencyHint: 'interactive' });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
throw new SystemAudioUnavailable(
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.audioWorklet.addModule(getWorkletModuleUrl());
|
||||||
|
} catch (err: unknown) {
|
||||||
|
await ctx.close().catch(() => undefined);
|
||||||
|
throw new SystemAudioUnavailable(
|
||||||
|
'audioWorklet load failed: ' +
|
||||||
|
(err instanceof Error ? err.message : String(err)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = new AudioWorkletNode(ctx, 'screen-audio-loopback', {
|
||||||
|
numberOfInputs: 0,
|
||||||
|
numberOfOutputs: 1,
|
||||||
|
outputChannelCount: [2],
|
||||||
|
});
|
||||||
|
const dest = ctx.createMediaStreamDestination();
|
||||||
|
node.connect(dest);
|
||||||
|
|
||||||
|
// Kick the AudioContext out of `suspended` before any samples arrive —
|
||||||
|
// the share is triggered from a user click so autoplay policy allows
|
||||||
|
// this, and an un-resumed context would buffer everything the Rust
|
||||||
|
// side produces until the context eventually runs, giving seconds of
|
||||||
|
// initial latency.
|
||||||
|
if (ctx.state !== 'running') {
|
||||||
|
try {
|
||||||
|
await ctx.resume();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('system-audio ctx.resume failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Channel, invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const channel = new Channel<AudioFramePayload>();
|
||||||
|
|
||||||
|
channel.onmessage = (frame: AudioFramePayload) => {
|
||||||
|
const bytes = base64ToBytes(frame.samplesBase64);
|
||||||
|
// Re-view the bytes as f32 little-endian. The byteLength is always
|
||||||
|
// a multiple of 8 (f32 stereo pairs) — if not, drop the trailing
|
||||||
|
// partial frame rather than risk a truncation artifact.
|
||||||
|
const sampleCount = Math.floor(bytes.byteLength / 4);
|
||||||
|
if (sampleCount < 2) return;
|
||||||
|
const floats = new Float32Array(
|
||||||
|
bytes.buffer,
|
||||||
|
bytes.byteOffset,
|
||||||
|
sampleCount,
|
||||||
|
);
|
||||||
|
// Interleaved L/R → deinterleaved for the worklet. Copying out of
|
||||||
|
// the base64 view also ensures the Float32Arrays we postMessage are
|
||||||
|
// owned (the underlying buffer is about to be garbage-collected).
|
||||||
|
const frames = floats.length >> 1;
|
||||||
|
const left = new Float32Array(frames);
|
||||||
|
const right = new Float32Array(frames);
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
left[i] = floats[i * 2] ?? 0;
|
||||||
|
right[i] = floats[i * 2 + 1] ?? 0;
|
||||||
|
}
|
||||||
|
// Transfer the buffers so postMessage is zero-copy.
|
||||||
|
node.port.postMessage(
|
||||||
|
{ left, right },
|
||||||
|
[left.buffer, right.buffer],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let captureId: number;
|
||||||
|
try {
|
||||||
|
captureId = await invoke<number>('start_system_audio_capture', { channel });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
node.disconnect();
|
||||||
|
await ctx.close().catch(() => undefined);
|
||||||
|
throw new SystemAudioUnavailable(
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = dest.stream;
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const stop = async (): Promise<void> => {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
try {
|
||||||
|
await invoke('stop_system_audio_capture', { captureId });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('stop_system_audio_capture failed', err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
node.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
|
for (const track of stream.getTracks()) {
|
||||||
|
try {
|
||||||
|
track.stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ctx.close().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { captureId, stream, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) {
|
||||||
|
bytes[i] = bin.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// Frontend side of the native screen-capture pipeline. Starts a Rust-side
|
||||||
|
// capture thread via `start_screen_capture` and streams JPEG frames back
|
||||||
|
// through a Tauri Channel. Each frame is decoded into an ImageBitmap,
|
||||||
|
// drawn onto an offscreen canvas, and the canvas' captureStream() is
|
||||||
|
// returned as a MediaStream that LiveKit can publishTrack() directly —
|
||||||
|
// no OS/browser screen picker is involved.
|
||||||
|
//
|
||||||
|
// Video-only: system audio would require WASAPI / ScreenCaptureKit hooks
|
||||||
|
// that xcap doesn't provide. Callers that request shared audio must
|
||||||
|
// either fall back to the browser picker or accept video-without-audio.
|
||||||
|
|
||||||
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
|
||||||
|
export interface NativeCaptureHandle {
|
||||||
|
/** Rust-side capture id. Pass to `stopNativeCapture` to tear down. */
|
||||||
|
captureId: number;
|
||||||
|
/** MediaStream fed by a canvas that's drawing each incoming frame. */
|
||||||
|
stream: MediaStream;
|
||||||
|
/** Cleanup — stops the Rust thread, closes channels, revokes the canvas
|
||||||
|
* stream. Idempotent. */
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FramePayload {
|
||||||
|
captureId: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
jpegBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returned when the runtime can't support native capture (no Tauri, no
|
||||||
|
* WebAudio, source vanished between enumeration and start, etc.). The
|
||||||
|
* caller is expected to fall back to the browser's getDisplayMedia path. */
|
||||||
|
export class NativeCaptureUnavailable extends Error {
|
||||||
|
constructor(reason: string) {
|
||||||
|
super('native capture unavailable: ' + reason);
|
||||||
|
this.name = 'NativeCaptureUnavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startNativeCapture(opts: {
|
||||||
|
sourceId: string;
|
||||||
|
maxWidth: number;
|
||||||
|
maxHeight: number;
|
||||||
|
fps: number;
|
||||||
|
}): Promise<NativeCaptureHandle> {
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
throw new NativeCaptureUnavailable('not a tauri runtime');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = opts.maxWidth;
|
||||||
|
canvas.height = opts.maxHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new NativeCaptureUnavailable('canvas 2d context unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track whether we got the first frame so we can fail fast if Rust
|
||||||
|
// reports "found" but then produces no output (e.g. screen was locked).
|
||||||
|
let firstFrameResolved = false;
|
||||||
|
let firstFrameResolve!: () => void;
|
||||||
|
let firstFrameReject!: (err: Error) => void;
|
||||||
|
const firstFramePromise = new Promise<void>((resolve, reject) => {
|
||||||
|
firstFrameResolve = resolve;
|
||||||
|
firstFrameReject = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { Channel, invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const channel = new Channel<FramePayload>();
|
||||||
|
|
||||||
|
// Latest-wins frame queue: if the JS side falls behind the Rust producer,
|
||||||
|
// we drop stale frames rather than queue them. Keeps memory flat and
|
||||||
|
// latency sensible for live screenshare.
|
||||||
|
let pendingFrame: FramePayload | null = null;
|
||||||
|
let decoding = false;
|
||||||
|
|
||||||
|
const drainQueue = async () => {
|
||||||
|
if (decoding) return;
|
||||||
|
decoding = true;
|
||||||
|
try {
|
||||||
|
while (pendingFrame) {
|
||||||
|
const frame = pendingFrame;
|
||||||
|
pendingFrame = null;
|
||||||
|
const bytes = base64ToBytes(frame.jpegBase64);
|
||||||
|
// Uint8Array's buffer type is `ArrayBufferLike` (could be a
|
||||||
|
// SharedArrayBuffer in theory); Blob wants plain ArrayBuffer.
|
||||||
|
// Pass the underlying buffer explicitly so the type narrows.
|
||||||
|
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||||
|
let bitmap: ImageBitmap;
|
||||||
|
try {
|
||||||
|
bitmap = await createImageBitmap(blob);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('createImageBitmap failed', err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
bitmap.close();
|
||||||
|
if (!firstFrameResolved) {
|
||||||
|
firstFrameResolved = true;
|
||||||
|
firstFrameResolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
decoding = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onmessage = (frame: FramePayload) => {
|
||||||
|
pendingFrame = frame;
|
||||||
|
void drainQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
let captureId: number;
|
||||||
|
try {
|
||||||
|
captureId = await invoke<number>('start_screen_capture', {
|
||||||
|
sourceId: opts.sourceId,
|
||||||
|
maxWidth: opts.maxWidth,
|
||||||
|
maxHeight: opts.maxHeight,
|
||||||
|
fps: opts.fps,
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
throw new NativeCaptureUnavailable(
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound the wait: the capture thread may fail silently on some sources
|
||||||
|
// (locked screens, protected windows). Fall back to getDisplayMedia in
|
||||||
|
// that case rather than hang the user.
|
||||||
|
const firstFrameTimeout = window.setTimeout(() => {
|
||||||
|
firstFrameReject(new Error('first frame timed out (3s)'));
|
||||||
|
}, 3000);
|
||||||
|
try {
|
||||||
|
await firstFramePromise;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
window.clearTimeout(firstFrameTimeout);
|
||||||
|
try {
|
||||||
|
await invoke('stop_screen_capture', { captureId });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new NativeCaptureUnavailable(
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
window.clearTimeout(firstFrameTimeout);
|
||||||
|
|
||||||
|
const stream = canvas.captureStream(opts.fps);
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const stop = async (): Promise<void> => {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
try {
|
||||||
|
await invoke('stop_screen_capture', { captureId });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('stop_screen_capture failed', err);
|
||||||
|
}
|
||||||
|
for (const track of stream.getTracks()) {
|
||||||
|
try {
|
||||||
|
track.stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { captureId, stream, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) {
|
||||||
|
bytes[i] = bin.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Session-only per-share audio volume. Mirror of `participantVolumes` but
|
||||||
|
// NOT persisted — when the user leaves the call or restarts the app, these
|
||||||
|
// reset to default. Intentional: the relevant trackSid is ephemeral anyway,
|
||||||
|
// and users don't expect screen-share volume to survive between sessions.
|
||||||
|
//
|
||||||
|
// Keys are participantIds (LiveKit identity). There's one screen-share per
|
||||||
|
// participant at a time in LiveKit, so keying by id keeps the API aligned
|
||||||
|
// with how the context menu surfaces the control ("Dennis's share").
|
||||||
|
|
||||||
|
const DEFAULT_VOLUME = 1;
|
||||||
|
// Matches Discord's slider range — up to 200% via the WebAudio GainNode in
|
||||||
|
// remoteAudioPipelines. HTMLMediaElement.volume only goes to 1.0, so the
|
||||||
|
// above-100% values are only meaningful on the WebAudio output path.
|
||||||
|
const MAX_VOLUME = 2;
|
||||||
|
|
||||||
|
type VolumeMap = Record<string, number>;
|
||||||
|
type Listener = (map: VolumeMap) => void;
|
||||||
|
|
||||||
|
let current: VolumeMap = {};
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
function clamp(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return DEFAULT_VOLUME;
|
||||||
|
if (v < 0) return 0;
|
||||||
|
if (v > MAX_VOLUME) return MAX_VOLUME;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
for (const fn of listeners) fn(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScreenShareVolume(userId: string): number {
|
||||||
|
return current[userId] ?? DEFAULT_VOLUME;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setScreenShareVolume(userId: string, volume: number): void {
|
||||||
|
const next = clamp(volume);
|
||||||
|
if (next === (current[userId] ?? DEFAULT_VOLUME)) return;
|
||||||
|
current = { ...current, [userId]: next };
|
||||||
|
applyToAttachedElements(userId, next);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeScreenShareVolumes(fn: Listener): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset on call end — called from CallContext when CallState.idle triggers.
|
||||||
|
export function clearScreenShareVolumes(): void {
|
||||||
|
if (Object.keys(current).length === 0) return;
|
||||||
|
current = {};
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-apply to any <audio> element already attached for this share. The
|
||||||
|
// elements are tagged by attachTrack in CallContext with
|
||||||
|
// `data-participant="<identity>"` + `data-track-source="screenshare"` — the
|
||||||
|
// combined selector makes sure we don't retarget the mic audio for the same
|
||||||
|
// user (different track-source). HTMLMediaElement.volume caps at 1.0, so
|
||||||
|
// clip here — the WebAudio GainNode on the live pipeline handles values
|
||||||
|
// above 1.
|
||||||
|
function applyToAttachedElements(userId: string, volume: number): void {
|
||||||
|
const elVolume = Math.min(1, Math.max(0, volume));
|
||||||
|
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
||||||
|
'audio[data-participant="' +
|
||||||
|
cssEscape(userId) +
|
||||||
|
'"][data-track-source="screenshare"]',
|
||||||
|
);
|
||||||
|
nodes.forEach((el) => {
|
||||||
|
el.volume = elVolume;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssEscape(v: string): string {
|
||||||
|
if (typeof (globalThis as { CSS?: { escape?: (s: string) => string } }).CSS
|
||||||
|
?.escape === 'function') {
|
||||||
|
return (globalThis as { CSS: { escape: (s: string) => string } }).CSS.escape(v);
|
||||||
|
}
|
||||||
|
return v.replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Frontend wrapper for the Rust `enumerate_screen_sources` command. Falls
|
||||||
|
// back to an empty list outside the Tauri runtime so a browser-only dev
|
||||||
|
// build (pnpm vite:dev in Chrome without Tauri) degrades gracefully to
|
||||||
|
// "nothing to show" rather than throwing.
|
||||||
|
|
||||||
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
|
||||||
|
export type ScreenSourceKind = 'screen' | 'window';
|
||||||
|
|
||||||
|
export interface ScreenSource {
|
||||||
|
/** Chromium-format source id ("screen:<id>:0" / "window:<hwnd>:0"). */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: ScreenSourceKind;
|
||||||
|
/** Base64-encoded JPEG without a data-URL prefix. Null when capture failed.
|
||||||
|
* Kept under `thumbnailPng` key for rollout stability — the server-side
|
||||||
|
* format switched from PNG to JPEG for payload size, but the field name
|
||||||
|
* preserves the wire contract during the transition. */
|
||||||
|
thumbnailPng: string | null;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast, metadata-only list. The picker uses this first so names show up
|
||||||
|
// immediately; thumbnails stream in via captureScreenSourceThumbnail below.
|
||||||
|
export async function listScreenSources(): Promise<ScreenSource[]> {
|
||||||
|
if (!isTauriRuntime()) return [];
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const raw = await invoke<ScreenSource[]>('list_screen_sources');
|
||||||
|
return raw ?? [];
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('list_screen_sources failed', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-source thumbnail capture (legacy base64 variant). Callers should
|
||||||
|
// prefer `captureScreenSourceThumbnailBytes` below — it ships raw JPEG
|
||||||
|
// bytes over IPC so the main thread avoids both the base64 decode AND
|
||||||
|
// the JSON parse overhead of a long string result. Kept for fallback.
|
||||||
|
export async function captureScreenSourceThumbnail(
|
||||||
|
sourceId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!isTauriRuntime()) return null;
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const result = await invoke<string | null>('capture_screen_source_thumbnail', {
|
||||||
|
sourceId,
|
||||||
|
});
|
||||||
|
return result ?? null;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('capture_screen_source_thumbnail failed', { sourceId, err });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail fetch with a two-tier fallback:
|
||||||
|
// 1. binary-IPC path (`..._bytes`) — ArrayBuffer over Tauri's raw channel
|
||||||
|
// 2. base64 path (legacy) — same command minus the ArrayBuffer wrapper
|
||||||
|
//
|
||||||
|
// The binary path can come through in several shapes depending on the
|
||||||
|
// Tauri / WebView2 version combo: a real ArrayBuffer, a Uint8Array, or
|
||||||
|
// occasionally a plain number[] when the response got re-serialised.
|
||||||
|
// We normalise all three into an ArrayBuffer before handing it to Blob.
|
||||||
|
// If the binary path returns nothing usable we retry once on the base64
|
||||||
|
// command — keeps thumbnails visible while the binary contract settles.
|
||||||
|
let warnedBinaryShape = false;
|
||||||
|
export async function captureScreenSourceThumbnailBytes(
|
||||||
|
sourceId: string,
|
||||||
|
): Promise<Blob | null> {
|
||||||
|
if (!isTauriRuntime()) return null;
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
|
||||||
|
// ---- Tier 1: binary IPC ---------------------------------------------
|
||||||
|
try {
|
||||||
|
const result = await invoke<ArrayBuffer | Uint8Array | number[] | null>(
|
||||||
|
'capture_screen_source_thumbnail_bytes',
|
||||||
|
{ sourceId },
|
||||||
|
);
|
||||||
|
let bytes: Uint8Array | null = null;
|
||||||
|
if (result instanceof ArrayBuffer) {
|
||||||
|
bytes = new Uint8Array(result);
|
||||||
|
} else if (result instanceof Uint8Array) {
|
||||||
|
bytes = result;
|
||||||
|
} else if (Array.isArray(result) && result.length > 0) {
|
||||||
|
bytes = new Uint8Array(result);
|
||||||
|
} else if (result && typeof result === 'object') {
|
||||||
|
// One-time diagnostic so we can see the unexpected shape in the
|
||||||
|
// console if WebView2 de-serialises the Response body into a bag
|
||||||
|
// of properties instead of a transferable binary buffer.
|
||||||
|
if (!warnedBinaryShape) {
|
||||||
|
warnedBinaryShape = true;
|
||||||
|
console.warn(
|
||||||
|
'capture_screen_source_thumbnail_bytes: unexpected shape, falling back',
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes && bytes.byteLength > 0) {
|
||||||
|
const copy = new Uint8Array(bytes.byteLength);
|
||||||
|
copy.set(bytes);
|
||||||
|
return new Blob([copy.buffer], { type: 'image/jpeg' });
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('binary thumbnail path threw, trying base64 fallback', {
|
||||||
|
sourceId,
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tier 2: base64 fallback ----------------------------------------
|
||||||
|
try {
|
||||||
|
const b64 = await invoke<string | null>('capture_screen_source_thumbnail', {
|
||||||
|
sourceId,
|
||||||
|
});
|
||||||
|
if (!b64) return null;
|
||||||
|
const bin = atob(b64);
|
||||||
|
const fallbackBytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) fallbackBytes[i] = bin.charCodeAt(i);
|
||||||
|
return new Blob([fallbackBytes.buffer], { type: 'image/jpeg' });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('base64 thumbnail fallback failed', { sourceId, err });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy single-shot variant. Captures everything serially on the Rust side
|
||||||
|
// before returning. Prefer listScreenSources + captureScreenSourceThumbnail
|
||||||
|
// for user-facing flows — they feel 5–10× more responsive in practice.
|
||||||
|
export async function enumerateScreenSources(): Promise<ScreenSource[]> {
|
||||||
|
if (!isTauriRuntime()) return [];
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const raw = await invoke<ScreenSource[]>('enumerate_screen_sources');
|
||||||
|
return raw ?? [];
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('enumerate_screen_sources failed', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data-URL helper — picker tiles bind `src={thumbnailDataUrl(src)}` so the
|
||||||
|
// thumbnail bytes never leave the component's render pass. Rust encodes
|
||||||
|
// JPEG now (smaller payload, faster decode); the mime type here must
|
||||||
|
// match or the <img> element silently fails to paint.
|
||||||
|
export function thumbnailDataUrl(src: ScreenSource): string | null {
|
||||||
|
if (!src.thumbnailPng) return null;
|
||||||
|
return 'data:image/jpeg;base64,' + src.thumbnailPng;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Toggle-style voice hotkeys (mute / deafen). Distinct from PTT (hold-style
|
||||||
|
// single-key). Keeps the Discord muscle-memory defaults — Ctrl+Shift+M for
|
||||||
|
// mute, Ctrl+Shift+D for deafen — but ships disabled so they never collide
|
||||||
|
// with something else on first run.
|
||||||
|
//
|
||||||
|
// Chord-capable: each binding stores a base key (KeyboardEvent.code) plus
|
||||||
|
// modifier flags. The keyLabel field is pre-rendered so UI and in-call hints
|
||||||
|
// don't have to derive it on every render.
|
||||||
|
|
||||||
|
import { keyCodeToLabel } from './pttSettings';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'chatapp.voiceHotkeys.v1';
|
||||||
|
|
||||||
|
export interface VoiceHotkeyBinding {
|
||||||
|
/** KeyboardEvent.code of the base key. */
|
||||||
|
key: string;
|
||||||
|
/** Pre-rendered label. Includes modifier prefixes, e.g. "Ctrl+Shift+M". */
|
||||||
|
keyLabel: string;
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceHotkeys {
|
||||||
|
mute: VoiceHotkeyBinding;
|
||||||
|
deafen: VoiceHotkeyBinding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoiceHotkeyKind = keyof VoiceHotkeys;
|
||||||
|
|
||||||
|
const DEFAULTS: VoiceHotkeys = {
|
||||||
|
mute: {
|
||||||
|
key: 'KeyM',
|
||||||
|
keyLabel: 'Ctrl+Shift+M',
|
||||||
|
ctrl: true,
|
||||||
|
shift: true,
|
||||||
|
alt: false,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
deafen: {
|
||||||
|
key: 'KeyD',
|
||||||
|
keyLabel: 'Ctrl+Shift+D',
|
||||||
|
ctrl: true,
|
||||||
|
shift: true,
|
||||||
|
alt: false,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Listener = (s: VoiceHotkeys) => void;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
let cached: VoiceHotkeys | null = null;
|
||||||
|
|
||||||
|
function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotkeyBinding {
|
||||||
|
if (!raw || typeof raw !== 'object') return fallback;
|
||||||
|
const b = raw as Partial<VoiceHotkeyBinding>;
|
||||||
|
return {
|
||||||
|
key: typeof b.key === 'string' && b.key ? b.key : fallback.key,
|
||||||
|
keyLabel: typeof b.keyLabel === 'string' && b.keyLabel ? b.keyLabel : fallback.keyLabel,
|
||||||
|
ctrl: typeof b.ctrl === 'boolean' ? b.ctrl : fallback.ctrl,
|
||||||
|
shift: typeof b.shift === 'boolean' ? b.shift : fallback.shift,
|
||||||
|
alt: typeof b.alt === 'boolean' ? b.alt : fallback.alt,
|
||||||
|
enabled: typeof b.enabled === 'boolean' ? b.enabled : fallback.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(): VoiceHotkeys {
|
||||||
|
if (cached) return cached;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
cached = DEFAULTS;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as Partial<VoiceHotkeys>;
|
||||||
|
cached = {
|
||||||
|
mute: validateBinding(parsed.mute, DEFAULTS.mute),
|
||||||
|
deafen: validateBinding(parsed.deafen, DEFAULTS.deafen),
|
||||||
|
};
|
||||||
|
return cached;
|
||||||
|
} catch {
|
||||||
|
cached = DEFAULTS;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(s: VoiceHotkeys): void {
|
||||||
|
cached = s;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||||
|
} catch {
|
||||||
|
/* quota / private mode */
|
||||||
|
}
|
||||||
|
for (const l of listeners) l(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVoiceHotkeys(): VoiceHotkeys {
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateVoiceHotkey(
|
||||||
|
kind: VoiceHotkeyKind,
|
||||||
|
patch: Partial<VoiceHotkeyBinding>,
|
||||||
|
): VoiceHotkeys {
|
||||||
|
const cur = read();
|
||||||
|
const next: VoiceHotkeys = {
|
||||||
|
...cur,
|
||||||
|
[kind]: {
|
||||||
|
...cur[kind],
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Keep the label in sync with the key + modifier flags so callers don't
|
||||||
|
// have to remember to pass keyLabel too.
|
||||||
|
next[kind].keyLabel = renderBindingLabel(next[kind]);
|
||||||
|
write(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeVoiceHotkeys(listener: Listener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derives "Ctrl+Shift+M" from a binding. The base key is normalised via
|
||||||
|
// keyCodeToLabel so "KeyM" → "M" etc, matching the PTT label rendering.
|
||||||
|
export function renderBindingLabel(b: VoiceHotkeyBinding): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (b.ctrl) parts.push('Ctrl');
|
||||||
|
if (b.shift) parts.push('Shift');
|
||||||
|
if (b.alt) parts.push('Alt');
|
||||||
|
parts.push(keyCodeToLabel(b.key));
|
||||||
|
return parts.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tauri global-shortcut accelerator string format. `CommandOrControl` maps
|
||||||
|
// to Cmd on macOS and Ctrl on Windows/Linux so the same binding works on
|
||||||
|
// every platform without platform-specific storage.
|
||||||
|
export function bindingToTauriShortcut(b: VoiceHotkeyBinding): string {
|
||||||
|
const base = b.key.startsWith('Key')
|
||||||
|
? b.key.slice(3)
|
||||||
|
: b.key.startsWith('Digit')
|
||||||
|
? b.key.slice(5)
|
||||||
|
: b.key;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (b.ctrl) parts.push('CommandOrControl');
|
||||||
|
if (b.shift) parts.push('Shift');
|
||||||
|
if (b.alt) parts.push('Alt');
|
||||||
|
parts.push(base);
|
||||||
|
return parts.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a browser KeyboardEvent matches a binding exactly (including
|
||||||
|
// modifier state). Used by the window-level fallback listener when the
|
||||||
|
// Tauri global-shortcut registration isn't available.
|
||||||
|
export function eventMatchesBinding(b: VoiceHotkeyBinding, e: KeyboardEvent): boolean {
|
||||||
|
if (!b.enabled) return false;
|
||||||
|
if (e.code !== b.key) return false;
|
||||||
|
if (e.ctrlKey !== b.ctrl && e.metaKey !== b.ctrl) return false;
|
||||||
|
if (e.shiftKey !== b.shift) return false;
|
||||||
|
if (e.altKey !== b.alt) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -29,15 +29,15 @@ export function AuthCallbackPage() {
|
|||||||
if (state.kind === 'done') return <Navigate to="/chats" replace />;
|
if (state.kind === 'done') return <Navigate to="/chats" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
|
<main className="flex min-h-screen items-center justify-center bg-surface p-6 text-fg">
|
||||||
{state.kind === 'pending' ? (
|
{state.kind === 'pending' ? (
|
||||||
<div className="flex items-center gap-3 text-neutral-400">
|
<div className="flex items-center gap-3 text-fg-muted">
|
||||||
<SpinnerIcon className="h-5 w-5 text-brand-400" />
|
<SpinnerIcon className="h-5 w-5 text-accent" />
|
||||||
<span className="text-sm font-medium">{t('common:finalising_session')}</span>
|
<span className="text-sm font-medium">{t('common:finalising_session')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex max-w-md items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-4 text-sm text-rose-100">
|
<div className="flex max-w-md items-start gap-3 rounded-lg border border-rose-500/25 bg-rose-500/10 p-4 text-sm text-rose-800 dark:text-rose-100">
|
||||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-600 dark:text-rose-400" />
|
||||||
<p className="min-w-0 flex-1 break-words">{state.message}</p>
|
<p className="min-w-0 flex-1 break-words">{state.message}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+180
-131
@@ -84,112 +84,109 @@ export function AuthPage() {
|
|||||||
[mode, email, username, inviteCode, i18n, t],
|
[mode, email, username, inviteCode, i18n, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Already signed in? Bounce to chats. Guards take it from here.
|
|
||||||
if (session) return <Navigate to="/chats" replace />;
|
if (session) return <Navigate to="/chats" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<main className="relative min-h-screen overflow-hidden bg-surface text-fg">
|
||||||
<BrandPanel />
|
<ShellBackground />
|
||||||
<FormCard
|
<div className="relative z-10 grid min-h-screen grid-cols-1 lg:grid-cols-[1fr_minmax(440px,520px)]">
|
||||||
mode={mode}
|
<BrandSection />
|
||||||
onModeChange={setMode}
|
<FormSection
|
||||||
email={email}
|
mode={mode}
|
||||||
onEmailChange={setEmail}
|
onModeChange={setMode}
|
||||||
username={username}
|
email={email}
|
||||||
onUsernameChange={setUsername}
|
onEmailChange={setEmail}
|
||||||
usernameValid={usernameValid}
|
username={username}
|
||||||
inviteCode={inviteCode}
|
onUsernameChange={setUsername}
|
||||||
onInviteChange={setInviteCode}
|
usernameValid={usernameValid}
|
||||||
ui={ui}
|
inviteCode={inviteCode}
|
||||||
onSubmit={handleSubmit}
|
onInviteChange={setInviteCode}
|
||||||
/>
|
ui={ui}
|
||||||
</Shell>
|
onSubmit={handleSubmit}
|
||||||
);
|
/>
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Layout
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function Shell({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<main className="relative min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
|
|
||||||
<BackgroundStage />
|
|
||||||
<div className="relative z-10 grid min-h-screen w-full grid-cols-1 gap-0 lg:grid-cols-[minmax(0,1fr)_minmax(440px,560px)]">
|
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackgroundStage() {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shell background — ambient blobs spread across the full viewport. Anchored
|
||||||
|
// to percentages so they stay in the same relative position regardless of
|
||||||
|
// monitor width (works as well on 1440 as on 3440 ultrawide).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ShellBackground() {
|
||||||
return (
|
return (
|
||||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
|
||||||
<div className="bg-grid absolute inset-0 opacity-[0.28]" />
|
<div className="bg-grid absolute inset-0 opacity-[0.12]" />
|
||||||
<div className="absolute -left-40 top-[22%] h-[560px] w-[560px] -translate-y-1/2 rounded-full bg-brand-500/30 blur-3xl animate-blob-a xl:h-[680px] xl:w-[680px] 2xl:h-[820px] 2xl:w-[820px]" />
|
<div className="absolute left-[15%] top-[18%] h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-accent/25 blur-3xl" />
|
||||||
<div className="absolute left-[38%] top-[60%] h-[520px] w-[520px] -translate-y-1/2 rounded-full bg-fuchsia-500/20 blur-3xl animate-blob-b xl:h-[640px] xl:w-[640px] 2xl:h-[780px] 2xl:w-[780px]" />
|
<div className="absolute left-[35%] top-[70%] h-[480px] w-[480px] -translate-x-1/2 rounded-full bg-fuchsia-500/15 blur-3xl" />
|
||||||
<div className="absolute -right-32 top-[12%] h-[420px] w-[420px] rounded-full bg-indigo-500/20 blur-3xl animate-blob-b xl:h-[520px] xl:w-[520px]" />
|
<div className="absolute left-[60%] top-[30%] h-[440px] w-[440px] -translate-x-1/2 rounded-full bg-indigo-500/15 blur-3xl" />
|
||||||
<div className="absolute -right-20 bottom-0 h-[460px] w-[460px] rounded-full bg-rose-500/10 blur-3xl animate-blob-a xl:h-[560px] xl:w-[560px]" />
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.45)_100%)]" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-ink-950/80" />
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.65)_100%)]" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrandPanel() {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Brand section — edge-to-edge panel on the left. Inner content constrained
|
||||||
|
// so it stays readable on ultrawide screens; ambient background fills the rest.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function BrandSection() {
|
||||||
const { t } = useTranslation(['auth', 'common']);
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
return (
|
return (
|
||||||
<section className="relative hidden lg:block">
|
<section className="relative hidden lg:flex">
|
||||||
<div className="grid h-full grid-rows-[auto_1fr_auto] px-10 py-10 xl:px-14 xl:py-14 2xl:px-20 2xl:py-16">
|
<DecorativeBubbles />
|
||||||
<header className="flex items-center justify-between gap-3">
|
<div className="relative mx-auto grid h-full w-full max-w-2xl grid-rows-[auto_1fr_auto] gap-8 px-10 py-10 xl:max-w-3xl xl:px-14 xl:py-14">
|
||||||
<div className="flex items-center gap-3">
|
<header className="flex items-center gap-3">
|
||||||
<LogoMark className="h-9 w-9" />
|
<LogoMark className="h-9 w-9" />
|
||||||
<span className="font-display text-lg font-semibold tracking-tight">
|
<span className="font-display text-lg font-semibold tracking-tight text-fg">
|
||||||
{t('common:app_name')}
|
{t('common:app_name')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex flex-col justify-center">
|
||||||
<div className="w-full max-w-xl animate-fade-in xl:max-w-2xl 2xl:max-w-3xl">
|
<span className="inline-flex w-fit items-center gap-2 rounded-full border border-line bg-surface-2/70 px-3 py-1 text-xs font-medium text-fg-muted backdrop-blur">
|
||||||
<p className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-neutral-300 backdrop-blur">
|
<ShieldIcon className="h-3.5 w-3.5 text-emerald-500 dark:text-emerald-400" />
|
||||||
<ShieldIcon className="h-3.5 w-3.5 text-emerald-400" />
|
{t('auth:brand.badge')}
|
||||||
{t('auth:brand.badge')}
|
</span>
|
||||||
</p>
|
|
||||||
<h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-white xl:text-5xl 2xl:text-6xl">
|
|
||||||
{t('auth:brand.title_line_1')}
|
|
||||||
<br />
|
|
||||||
{t('auth:brand.title_line_2')}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-5 max-w-lg text-base leading-relaxed text-neutral-400 xl:text-lg 2xl:max-w-xl">
|
|
||||||
{t('auth:brand.subtitle')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<dl className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:mt-12 xl:gap-5 2xl:grid-cols-3">
|
<h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-fg xl:text-5xl 2xl:text-6xl">
|
||||||
<Feature
|
{t('auth:brand.title_line_1')}
|
||||||
icon={<LockIcon className="h-5 w-5 text-brand-300" />}
|
<br />
|
||||||
title={t('auth:brand.feature_zk_title')}
|
<span className="text-accent">{t('auth:brand.title_line_2')}</span>
|
||||||
desc={t('auth:brand.feature_zk_desc')}
|
</h1>
|
||||||
/>
|
|
||||||
<Feature
|
<p className="mt-5 max-w-xl text-base leading-relaxed text-fg-muted xl:text-lg">
|
||||||
icon={<SparklesIcon className="h-5 w-5 text-brand-300" />}
|
{t('auth:brand.subtitle')}
|
||||||
title={t('auth:brand.feature_selfhost_title')}
|
</p>
|
||||||
desc={t('auth:brand.feature_selfhost_desc')}
|
|
||||||
/>
|
<ul className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-1 xl:mt-12 xl:gap-4">
|
||||||
<Feature
|
<FeatureRow
|
||||||
icon={<ShieldIcon className="h-5 w-5 text-brand-300" />}
|
icon={<LockIcon className="h-4 w-4" />}
|
||||||
title={t('auth:brand.feature_invite_title')}
|
title={t('auth:brand.feature_zk_title')}
|
||||||
desc={t('auth:brand.feature_invite_desc')}
|
desc={t('auth:brand.feature_zk_desc')}
|
||||||
/>
|
/>
|
||||||
</dl>
|
<FeatureRow
|
||||||
</div>
|
icon={<SparklesIcon className="h-4 w-4" />}
|
||||||
|
title={t('auth:brand.feature_selfhost_title')}
|
||||||
|
desc={t('auth:brand.feature_selfhost_desc')}
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<ShieldIcon className="h-4 w-4" />}
|
||||||
|
title={t('auth:brand.feature_invite_title')}
|
||||||
|
desc={t('auth:brand.feature_invite_desc')}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="flex items-center justify-between text-xs text-neutral-500">
|
<footer className="flex items-center justify-between gap-3 text-xs text-fg-muted">
|
||||||
<span>v0.1.0 · {t('common:dev_build')}</span>
|
<span className="font-mono">v0.1.0 · {t('common:dev_build')}</span>
|
||||||
<span className="inline-flex items-center gap-1.5 text-neutral-600">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-60" />
|
||||||
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
{t('common:local_stack_online')}
|
{t('common:local_stack_online')}
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -198,19 +195,49 @@ function BrandPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Feature({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) {
|
// Subtle chat-bubble silhouettes in the far background — gives the brand side
|
||||||
|
// product context without competing with the typographic content.
|
||||||
|
function DecorativeBubbles() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-white/5 bg-white/5 p-4 backdrop-blur">
|
<div
|
||||||
<div className="flex items-center gap-2">
|
aria-hidden="true"
|
||||||
{icon}
|
className="pointer-events-none absolute inset-0 hidden overflow-hidden opacity-60 xl:block"
|
||||||
<dt className="text-sm font-semibold text-white">{title}</dt>
|
>
|
||||||
</div>
|
<div className="absolute right-[8%] top-[22%] h-16 w-44 rounded-2xl rounded-bl-sm border border-line bg-surface-2/40 backdrop-blur-sm" />
|
||||||
<dd className="mt-1.5 text-sm text-neutral-400">{desc}</dd>
|
<div className="absolute right-[18%] top-[42%] h-12 w-32 rounded-2xl rounded-br-sm border border-accent/20 bg-accent/10 backdrop-blur-sm" />
|
||||||
|
<div className="absolute right-[6%] top-[58%] h-14 w-40 rounded-2xl rounded-bl-sm border border-line bg-surface-2/40 backdrop-blur-sm" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormCardProps {
|
function FeatureRow({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/20">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 pt-1">
|
||||||
|
<p className="text-sm font-semibold text-fg xl:text-base">{title}</p>
|
||||||
|
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted xl:text-sm">{desc}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Form section — fixed-width panel on the right. Full viewport height,
|
||||||
|
// bg-surface-2 for visual distinction from the brand side.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface FormSectionProps {
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
onModeChange: (m: Mode) => void;
|
onModeChange: (m: Mode) => void;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -224,7 +251,7 @@ interface FormCardProps {
|
|||||||
onSubmit: (e: React.FormEvent) => void;
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormCard({
|
function FormSection({
|
||||||
mode,
|
mode,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
email,
|
email,
|
||||||
@@ -236,7 +263,7 @@ function FormCard({
|
|||||||
onInviteChange,
|
onInviteChange,
|
||||||
ui,
|
ui,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: FormCardProps) {
|
}: FormSectionProps) {
|
||||||
const { t } = useTranslation(['auth']);
|
const { t } = useTranslation(['auth']);
|
||||||
const busy = ui.kind === 'sending';
|
const busy = ui.kind === 'sending';
|
||||||
const emailId = useId();
|
const emailId = useId();
|
||||||
@@ -249,22 +276,30 @@ function FormCard({
|
|||||||
const ctaSendingKey = mode === 'signup' ? 'auth:signup.cta_sending' : 'auth:login.cta_sending';
|
const ctaSendingKey = mode === 'signup' ? 'auth:signup.cta_sending' : 'auth:login.cta_sending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative flex items-center justify-center px-5 py-10 sm:px-8 lg:px-10 xl:px-16">
|
<section className="relative flex flex-col border-t border-line bg-surface-2/80 backdrop-blur-xl lg:border-l lg:border-t-0">
|
||||||
<div className="absolute left-6 right-6 top-6 flex items-center justify-between lg:hidden">
|
{/* Mobile-only header strip */}
|
||||||
|
<div className="flex items-center justify-between border-b border-line px-5 py-4 lg:hidden">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<LogoMark className="h-7 w-7" />
|
<LogoMark className="h-7 w-7" />
|
||||||
<span className="font-display text-base font-semibold tracking-tight">ChatApp</span>
|
<span className="font-display text-base font-semibold tracking-tight text-fg">
|
||||||
|
ChatApp
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<LanguageSwitcher compact />
|
<LanguageSwitcher compact />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-md animate-slide-up">
|
{/* Desktop-only top strip — LanguageSwitcher in the corner */}
|
||||||
<div className="rounded-2xl border border-white/10 bg-ink-900/70 p-6 shadow-glow backdrop-blur-xl sm:p-8">
|
<div className="hidden items-center justify-end px-8 pt-8 lg:flex">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center">
|
||||||
|
<div className="mx-auto w-full max-w-md px-6 py-8 sm:px-8 lg:px-10 lg:py-10">
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<h2 className="font-display text-2xl font-semibold tracking-tight text-white">
|
<h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||||
{t(titleKey)}
|
{t(titleKey)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1.5 text-sm text-neutral-400">{t(subtitleKey)}</p>
|
<p className="mt-1.5 text-sm text-fg-muted">{t(subtitleKey)}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Segmented mode={mode} onChange={onModeChange} />
|
<Segmented mode={mode} onChange={onModeChange} />
|
||||||
@@ -285,7 +320,7 @@ function FormCard({
|
|||||||
placeholder={t('auth:fields.email_placeholder')}
|
placeholder={t('auth:fields.email_placeholder')}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => onEmailChange(e.target.value)}
|
onChange={(e) => onEmailChange(e.target.value)}
|
||||||
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
className={INPUT_CLASS}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -316,7 +351,7 @@ function FormCard({
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => onUsernameChange(e.target.value)}
|
onChange={(e) => onUsernameChange(e.target.value)}
|
||||||
pattern={USERNAME_PATTERN.source}
|
pattern={USERNAME_PATTERN.source}
|
||||||
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
className={INPUT_CLASS}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -336,7 +371,7 @@ function FormCard({
|
|||||||
required
|
required
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChange={(e) => onInviteChange(e.target.value)}
|
onChange={(e) => onInviteChange(e.target.value)}
|
||||||
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm font-mono text-white transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
className={INPUT_CLASS + ' font-mono'}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -347,7 +382,7 @@ function FormCard({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-busy={busy}
|
aria-busy={busy}
|
||||||
className="group inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
className="group inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-accent-fg transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<>
|
<>
|
||||||
@@ -367,25 +402,30 @@ function FormCard({
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Footer mode={mode} onModeChange={onModeChange} />
|
<Footer mode={mode} onModeChange={onModeChange} />
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-6 text-center text-xs text-neutral-500">{t('auth:legal_note')}</p>
|
<p className="mt-6 text-center text-xs text-fg-muted">
|
||||||
|
{t('auth:legal_note')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INPUT_CLASS =
|
||||||
|
'w-full rounded-lg border border-line bg-surface-3 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30';
|
||||||
|
|
||||||
function Segmented({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
function Segmented({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
||||||
const { t } = useTranslation(['auth']);
|
const { t } = useTranslation(['auth']);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Authentication mode"
|
aria-label="Authentication mode"
|
||||||
className="relative grid grid-cols-2 rounded-lg border border-white/10 bg-ink-800 p-1 text-sm"
|
className="relative grid grid-cols-2 rounded-lg border border-line bg-surface-3 p-1 text-sm"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-brand-500/20 ring-1 ring-brand-400/40 transition-transform duration-200"
|
className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-accent/15 ring-1 ring-accent/30 transition-transform duration-200"
|
||||||
style={{ transform: 'translateX(' + (mode === 'signup' ? '0%' : 'calc(100% + 4px)') + ')' }}
|
style={{ transform: 'translateX(' + (mode === 'signup' ? '0%' : 'calc(100% + 4px)') + ')' }}
|
||||||
/>
|
/>
|
||||||
<SegmentButton active={mode === 'signup'} onClick={() => onChange('signup')}>
|
<SegmentButton active={mode === 'signup'} onClick={() => onChange('signup')}>
|
||||||
@@ -415,7 +455,7 @@ function SegmentButton({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={
|
className={
|
||||||
'relative z-10 cursor-pointer rounded-md px-3 py-2 font-medium transition focus:outline-none ' +
|
'relative z-10 cursor-pointer rounded-md px-3 py-2 font-medium transition focus:outline-none ' +
|
||||||
(active ? 'text-white' : 'text-neutral-400 hover:text-neutral-200')
|
(active ? 'text-fg' : 'text-fg-muted hover:text-fg')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -440,17 +480,27 @@ function Field({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor={id} className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500">
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-fg-muted">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
{input}
|
{input}
|
||||||
</div>
|
</div>
|
||||||
{hint && (
|
{hint && (
|
||||||
<p className={'text-xs ' + (invalid ? 'text-rose-400' : 'text-neutral-500')}>{hint}</p>
|
<p
|
||||||
|
className={
|
||||||
|
'text-[11px] leading-snug ' +
|
||||||
|
(invalid ? 'text-rose-600 dark:text-rose-400' : 'text-fg-muted')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -462,14 +512,14 @@ function StatusBanner({ ui }: { ui: UiState }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
className="flex items-start gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-3.5 text-sm text-emerald-100"
|
className="flex items-start gap-3 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-3.5 text-sm text-emerald-800 dark:text-emerald-100"
|
||||||
>
|
>
|
||||||
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" />
|
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<p className="break-words font-medium">
|
<p className="break-words font-medium">
|
||||||
{t('auth:sent_banner', { email: ui.email })}
|
{t('auth:sent_banner', { email: ui.email })}
|
||||||
</p>
|
</p>
|
||||||
<p className="break-words text-xs text-emerald-200/80">
|
<p className="break-words text-xs text-emerald-700/90 dark:text-emerald-200/80">
|
||||||
<InbucketHint />
|
<InbucketHint />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -481,9 +531,9 @@ function StatusBanner({ ui }: { ui: UiState }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3.5 text-sm text-rose-100"
|
className="flex items-start gap-3 rounded-lg border border-rose-500/25 bg-rose-500/10 p-3.5 text-sm text-rose-800 dark:text-rose-100"
|
||||||
>
|
>
|
||||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-600 dark:text-rose-400" />
|
||||||
<p className="min-w-0 flex-1 break-words">{ui.message}</p>
|
<p className="min-w-0 flex-1 break-words">{ui.message}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -503,7 +553,7 @@ function InbucketHint() {
|
|||||||
href="http://127.0.0.1:54324"
|
href="http://127.0.0.1:54324"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="underline underline-offset-2 hover:text-white"
|
className="underline underline-offset-2 hover:text-emerald-900 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
Inbucket
|
Inbucket
|
||||||
</a>
|
</a>
|
||||||
@@ -527,7 +577,6 @@ function OtpForm({ email }: { email: string }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await verifyMagicLinkOtp(supabase, email, token);
|
await verifyMagicLinkOtp(supabase, email, token);
|
||||||
// Session updates via Supabase subscription; AuthPage Navigate redirects.
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const code = extractErrorCode(err);
|
const code = extractErrorCode(err);
|
||||||
setError(
|
setError(
|
||||||
@@ -543,10 +592,10 @@ function OtpForm({ email }: { email: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-2 rounded-lg border border-white/10 bg-ink-800/50 p-4">
|
<div className="mt-4 space-y-2 rounded-lg border border-line bg-surface-3 p-4">
|
||||||
<label
|
<label
|
||||||
htmlFor={inputId}
|
htmlFor={inputId}
|
||||||
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
|
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted"
|
||||||
>
|
>
|
||||||
{t('auth:otp_label')}
|
{t('auth:otp_label')}
|
||||||
</label>
|
</label>
|
||||||
@@ -567,14 +616,14 @@ function OtpForm({ email }: { email: string }) {
|
|||||||
}}
|
}}
|
||||||
placeholder={t('auth:otp_placeholder')}
|
placeholder={t('auth:otp_placeholder')}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full rounded-lg border border-white/10 bg-ink-900 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-white placeholder-neutral-600 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-fg placeholder-fg-muted/50 transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-neutral-500">{t('auth:otp_hint')}</p>
|
<p className="text-[11px] text-fg-muted">{t('auth:otp_hint')}</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
className="break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200"
|
className="break-words rounded-md border border-rose-500/25 bg-rose-500/10 px-3 py-2 text-xs text-rose-800 dark:text-rose-200"
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
@@ -585,7 +634,7 @@ function OtpForm({ email }: { email: string }) {
|
|||||||
disabled={busy || token.length !== 6}
|
disabled={busy || token.length !== 6}
|
||||||
onClick={(e) => void handleVerify(e)}
|
onClick={(e) => void handleVerify(e)}
|
||||||
aria-busy={busy}
|
aria-busy={busy}
|
||||||
className="mt-1 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
|
className="mt-1 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-semibold text-accent-fg transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||||
<span>{t(busy ? 'auth:otp_cta_loading' : 'auth:otp_cta')}</span>
|
<span>{t(busy ? 'auth:otp_cta_loading' : 'auth:otp_cta')}</span>
|
||||||
@@ -601,13 +650,13 @@ function Footer({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
|
|||||||
mode === 'signup' ? 'auth:footer_switch_to_login' : 'auth:footer_switch_to_signup';
|
mode === 'signup' ? 'auth:footer_switch_to_login' : 'auth:footer_switch_to_signup';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 flex items-center justify-between border-t border-white/5 pt-5 text-xs text-neutral-500">
|
<div className="mt-6 flex items-center justify-between border-t border-line pt-5 text-xs text-fg-muted">
|
||||||
<span>
|
<span>
|
||||||
{t(promptKey)}{' '}
|
{t(promptKey)}{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')}
|
onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')}
|
||||||
className="cursor-pointer font-medium text-brand-300 underline-offset-2 hover:text-brand-200 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-400/40 focus:ring-offset-2 focus:ring-offset-ink-900"
|
className="cursor-pointer font-semibold text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-2"
|
||||||
>
|
>
|
||||||
{t(switchKey)}
|
{t(switchKey)}
|
||||||
</button>
|
</button>
|
||||||
@@ -616,7 +665,7 @@ function Footer({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
|
|||||||
href="http://127.0.0.1:54323"
|
href="http://127.0.0.1:54323"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="hover:text-neutral-300"
|
className="hover:text-fg"
|
||||||
>
|
>
|
||||||
{t('auth:footer_studio')} ↗
|
{t('auth:footer_studio')} ↗
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import {
|
|||||||
subscribePttSettings,
|
subscribePttSettings,
|
||||||
updatePttSettings,
|
updatePttSettings,
|
||||||
} from '../lib/pttSettings';
|
} from '../lib/pttSettings';
|
||||||
|
import {
|
||||||
|
getVoiceHotkeys,
|
||||||
|
subscribeVoiceHotkeys,
|
||||||
|
updateVoiceHotkey,
|
||||||
|
type VoiceHotkeyKind,
|
||||||
|
type VoiceHotkeys,
|
||||||
|
} from '../lib/voiceHotkeys';
|
||||||
import {
|
import {
|
||||||
AUDIO_QUALITY_ORDER,
|
AUDIO_QUALITY_ORDER,
|
||||||
type AudioQuality,
|
type AudioQuality,
|
||||||
@@ -163,6 +170,12 @@ export function SettingsPage() {
|
|||||||
<div className="mt-3 border-t border-line pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<PttControls />
|
<PttControls />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
|
<VoiceHotkeyControls kind="mute" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
|
<VoiceHotkeyControls kind="deafen" />
|
||||||
|
</div>
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<CallE2EEControls />
|
<CallE2EEControls />
|
||||||
</div>
|
</div>
|
||||||
@@ -263,6 +276,96 @@ function PttControls() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [hotkeys, setHotkeys] = useState<VoiceHotkeys>(() => getVoiceHotkeys());
|
||||||
|
const [capturing, setCapturing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => subscribeVoiceHotkeys(setHotkeys), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!capturing) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
// Modifier-only presses shouldn't bind — wait for a real key to
|
||||||
|
// arrive. Escape aborts the capture.
|
||||||
|
if (e.code === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setCapturing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
e.code === 'ControlLeft' ||
|
||||||
|
e.code === 'ControlRight' ||
|
||||||
|
e.code === 'ShiftLeft' ||
|
||||||
|
e.code === 'ShiftRight' ||
|
||||||
|
e.code === 'AltLeft' ||
|
||||||
|
e.code === 'AltRight' ||
|
||||||
|
e.code === 'MetaLeft' ||
|
||||||
|
e.code === 'MetaRight'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
updateVoiceHotkey(kind, {
|
||||||
|
key: e.code,
|
||||||
|
ctrl: e.ctrlKey || e.metaKey,
|
||||||
|
shift: e.shiftKey,
|
||||||
|
alt: e.altKey,
|
||||||
|
});
|
||||||
|
setCapturing(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, { capture: true });
|
||||||
|
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||||||
|
}, [capturing, kind]);
|
||||||
|
|
||||||
|
const binding = hotkeys[kind];
|
||||||
|
const toggleLabel =
|
||||||
|
kind === 'mute'
|
||||||
|
? t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' })
|
||||||
|
: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' });
|
||||||
|
const toggleHint =
|
||||||
|
kind === 'mute'
|
||||||
|
? t('app:settings.hotkey_mute_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
|
||||||
|
})
|
||||||
|
: t('app:settings.hotkey_deafen_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toggle
|
||||||
|
label={toggleLabel}
|
||||||
|
hint={toggleHint}
|
||||||
|
checked={binding.enabled}
|
||||||
|
onChange={(v) => updateVoiceHotkey(kind, { enabled: v })}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCapturing((v) => !v)}
|
||||||
|
className={
|
||||||
|
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
|
(capturing
|
||||||
|
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||||||
|
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{capturing
|
||||||
|
? t('app:settings.hotkey_press_combo', {
|
||||||
|
defaultValue: 'Kombination drücken…',
|
||||||
|
})
|
||||||
|
: binding.keyLabel}
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CallE2EEControls() {
|
function CallE2EEControls() {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
|
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
|
||||||
|
|||||||
Reference in New Issue
Block a user