Skip to content
Clone

feat(voice): integrate LiveKit for voice channel functionality and add voice-related APIs

ZhenYi committed
parent 0aa5835 commit c6d9bbe Browse files

41 files changed

+5180 −101

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 41
Modified config/default.toml View config/default.toml
@@ -379,3 +379,17 @@
379379log_flush_bytes = 65536
380380keep_workspace = false
381381once = false
382
383[livekit]
384enabled = false
385url = "ws://127.0.0.1:7880"
386api_url = "http://127.0.0.1:7880"
387api_key = ""
388api_secret = ""
389token_ttl_secs = 7200
390empty_timeout_secs = 300
391departure_timeout_secs = 20
392default_max_participants = 50
393presence_ttl_secs = 43200
394hand_ttl_secs = 3600
395recording_enabled = false
Modified lib/chat/src/handlers/mod.rs View lib/chat/src/handlers/mod.rs
@@ -13,4 +13,5 @@
1313pub mod search;
1414pub mod settings;
1515pub mod threads;
16pub mod voice;
1617pub mod webhooks;
Added lib/chat/src/handlers/voice.rs View lib/chat/src/handlers/voice.rs
@@ -0,0 +1,492 @@
1use axum::extract::{Path, Query, State};
2use axum::http::HeaderMap;
3use export::rest::{AppResponseData, NoData};
4use livekit_api::access_token::TokenVerifier;
5use livekit_api::webhooks::WebhookReceiver;
6use serde::Deserialize;
7use session::Session;
8use session::share::session::require_uid;
9use uuid::Uuid;
10
11use crate::errors::ApiError;
12use crate::services::service::ChatService;
13use crate::services::voice::{
14 HandDto, VoiceConfigDto, VoiceHandsDto, VoiceModerationDto, VoiceParticipantDto,
15 VoiceRecordingDto, VoiceSpaceStatesDto, VoiceTokenDto,
16};
17
18#[utoipa::path(
19 get,
20 path = "/chat/voice/config",
21 tag = "chat",
22 description = "语音功能的搭接参数;未启用时 enabled=false,前端隐藏入口。",
23 responses(
24 (status = 200, description = "voice config", body = AppResponseData<VoiceConfigDto>),
25 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
26 )
27)]
28pub async fn voice_config(
29 State(service): State<ChatService>,
30 session: Session,
31) -> Result<AppResponseData<VoiceConfigDto>, ApiError> {
32 let _uid = require_uid(&session)?;
33 Ok(AppResponseData::ok(service.voice_config()))
34}
35
36#[utoipa::path(
37 post,
38 path = "/chat/rooms/{room_id}/voice/token",
39 tag = "chat",
40 description = "为当前用户签发进入该语音频道的 LiveKit 令牌;房间满员返回 409。",
41 params(("room_id" = Uuid, Path, description = "room id")),
42 responses(
43 (status = 200, description = "voice token", body = AppResponseData<VoiceTokenDto>),
44 (status = 400, description = "not a voice channel or livekit disabled", body = AppResponseData<NoData>),
45 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
46 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
47 (status = 409, description = "room is full", body = AppResponseData<NoData>)
48 )
49)]
50pub async fn voice_token(
51 State(service): State<ChatService>,
52 session: Session,
53 Path(room_id): Path<Uuid>,
54) -> Result<AppResponseData<VoiceTokenDto>, ApiError> {
55 let uid = require_uid(&session)?;
56 Ok(AppResponseData::ok(service.voice_token(uid, room_id).await?))
57}
58
59#[utoipa::path(
60 get,
61 path = "/chat/rooms/{room_id}/voice/participants",
62 tag = "chat",
63 description = "以 LiveKit 实时状态为准返回通话参与者,并回写 Redis 在场记录。",
64 params(("room_id" = Uuid, Path, description = "room id")),
65 responses(
66 (status = 200, description = "participants", body = AppResponseData<Vec<VoiceParticipantDto>>),
67 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
68 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
69 )
70)]
71pub async fn voice_participants(
72 State(service): State<ChatService>,
73 session: Session,
74 Path(room_id): Path<Uuid>,
75) -> Result<AppResponseData<Vec<VoiceParticipantDto>>, ApiError> {
76 let uid = require_uid(&session)?;
77 Ok(AppResponseData::ok(
78 service.voice_participants(uid, room_id).await?,
79 ))
80}
81
82#[utoipa::path(
83 get,
84 path = "/chat/spaces/{space_name}/voice-states",
85 tag = "chat",
86 description = "侧栏用:该空间下每个语音/舞台频道当前在线人数。",
87 params(("space_name" = String, Path, description = "space name")),
88 responses(
89 (status = 200, description = "voice states", body = AppResponseData<VoiceSpaceStatesDto>),
90 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
91 )
92)]
93pub async fn voice_space_states(
94 State(service): State<ChatService>,
95 session: Session,
96 Path(space_name): Path<String>,
97) -> Result<AppResponseData<VoiceSpaceStatesDto>, ApiError> {
98 let uid = require_uid(&session)?;
99 Ok(AppResponseData::ok(
100 service.voice_space_states(uid, &space_name).await?,
101 ))
102}
103
104#[utoipa::path(
105 get,
106 path = "/chat/rooms/{room_id}/voice/hands",
107 tag = "chat",
108 description = "舞台举手列表;主持人看到全部申请,普通成员只看到自己与已通过者。",
109 params(("room_id" = Uuid, Path, description = "room id")),
110 responses(
111 (status = 200, description = "hands", body = AppResponseData<VoiceHandsDto>),
112 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
113 )
114)]
115pub async fn list_hands(
116 State(service): State<ChatService>,
117 session: Session,
118 Path(room_id): Path<Uuid>,
119) -> Result<AppResponseData<VoiceHandsDto>, ApiError> {
120 let uid = require_uid(&session)?;
121 Ok(AppResponseData::ok(service.list_hands(uid, room_id).await?))
122}
123
124#[utoipa::path(
125 post,
126 path = "/chat/rooms/{room_id}/voice/hands",
127 tag = "chat",
128 description = "在舞台频道举手申请发言。",
129 params(("room_id" = Uuid, Path, description = "room id")),
130 responses(
131 (status = 200, description = "hand raised", body = AppResponseData<HandDto>),
132 (status = 400, description = "not a stage channel", body = AppResponseData<NoData>),
133 (status = 409, description = "already able to speak", body = AppResponseData<NoData>)
134 )
135)]
136pub async fn raise_hand(
137 State(service): State<ChatService>,
138 session: Session,
139 Path(room_id): Path<Uuid>,
140) -> Result<AppResponseData<HandDto>, ApiError> {
141 let uid = require_uid(&session)?;
142 Ok(AppResponseData::ok(service.raise_hand(uid, room_id).await?))
143}
144
145#[utoipa::path(
146 delete,
147 path = "/chat/rooms/{room_id}/voice/hands/@me",
148 tag = "chat",
149 description = "撤回自己的举手申请。",
150 params(("room_id" = Uuid, Path, description = "room id")),
151 responses(
152 (status = 200, description = "hand cancelled", body = AppResponseData<NoData>),
153 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
154 (status = 409, description = "an approved speaker must be revoked by a moderator", body = AppResponseData<NoData>)
155 )
156)]
157pub async fn cancel_hand(
158 State(service): State<ChatService>,
159 session: Session,
160 Path(room_id): Path<Uuid>,
161) -> Result<AppResponseData<NoData>, ApiError> {
162 let uid = require_uid(&session)?;
163 service.cancel_hand(uid, room_id).await?;
164 Ok(AppResponseData::empty())
165}
166
167#[utoipa::path(
168 post,
169 path = "/chat/rooms/{room_id}/voice/hands/{user_id}/approve",
170 tag = "chat",
171 description = "主持人批准发言:翻转该成员 can_publish,无需重连。",
172 params(
173 ("room_id" = Uuid, Path, description = "room id"),
174 ("user_id" = Uuid, Path, description = "target user id")
175 ),
176 responses(
177 (status = 200, description = "approved", body = AppResponseData<VoiceModerationDto>),
178 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
179 (status = 404, description = "hand not found", body = AppResponseData<NoData>)
180 )
181)]
182pub async fn approve_hand(
183 State(service): State<ChatService>,
184 session: Session,
185 Path((room_id, user_id)): Path<(Uuid, Uuid)>,
186) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
187 let uid = require_uid(&session)?;
188 Ok(AppResponseData::ok(
189 service.approve_hand(uid, room_id, user_id).await?,
190 ))
191}
192
193#[utoipa::path(
194 post,
195 path = "/chat/rooms/{room_id}/voice/hands/{user_id}/deny",
196 tag = "chat",
197 description = "主持人拒绝发言申请。",
198 params(
199 ("room_id" = Uuid, Path, description = "room id"),
200 ("user_id" = Uuid, Path, description = "target user id")
201 ),
202 responses(
203 (status = 200, description = "denied", body = AppResponseData<VoiceModerationDto>),
204 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
205 )
206)]
207pub async fn deny_hand(
208 State(service): State<ChatService>,
209 session: Session,
210 Path((room_id, user_id)): Path<(Uuid, Uuid)>,
211) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
212 let uid = require_uid(&session)?;
213 Ok(AppResponseData::ok(
214 service.deny_hand(uid, room_id, user_id).await?,
215 ))
216}
217
218#[utoipa::path(
219 post,
220 path = "/chat/rooms/{room_id}/voice/hands/{user_id}/revoke",
221 tag = "chat",
222 description = "主持人收回某人的发言权:can_publish=false 并静音其麦克风。",
223 params(
224 ("room_id" = Uuid, Path, description = "room id"),
225 ("user_id" = Uuid, Path, description = "target user id")
226 ),
227 responses(
228 (status = 200, description = "revoked", body = AppResponseData<VoiceModerationDto>),
229 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
230 )
231)]
232pub async fn revoke_hand(
233 State(service): State<ChatService>,
234 session: Session,
235 Path((room_id, user_id)): Path<(Uuid, Uuid)>,
236) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
237 let uid = require_uid(&session)?;
238 Ok(AppResponseData::ok(
239 service.revoke_hand(uid, room_id, user_id).await?,
240 ))
241}
242
243#[derive(Debug, Clone, Deserialize)]
244pub struct ModerateQuery {
245 #[serde(default)]
246 pub muted: bool,
247}
248
249#[utoipa::path(
250 post,
251 path = "/chat/rooms/{room_id}/voice/participants/{user_id}/mute",
252 tag = "chat",
253 description = "主持人强制静音或解除静音某人的麦克风(?muted=false 解除)。",
254 params(
255 ("room_id" = Uuid, Path, description = "room id"),
256 ("user_id" = Uuid, Path, description = "target user id"),
257 ("muted" = Option<bool>, Query, description = "false to unmute")
258 ),
259 responses(
260 (status = 200, description = "moderated", body = AppResponseData<VoiceModerationDto>),
261 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
262 )
263)]
264pub async fn moderate_mute(
265 State(service): State<ChatService>,
266 session: Session,
267 Path((room_id, user_id)): Path<(Uuid, Uuid)>,
268 Query(query): Query<ModerateQuery>,
269) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
270 let uid = require_uid(&session)?;
271 Ok(AppResponseData::ok(
272 service
273 .moderate_mute(uid, room_id, user_id, query.muted)
274 .await?,
275 ))
276}
277
278#[utoipa::path(
279 delete,
280 path = "/chat/rooms/{room_id}/voice/participants/{user_id}",
281 tag = "chat",
282 description = "主持人把某人移出通话;该用户可以再次加入。",
283 params(
284 ("room_id" = Uuid, Path, description = "room id"),
285 ("user_id" = Uuid, Path, description = "target user id")
286 ),
287 responses(
288 (status = 200, description = "removed", body = AppResponseData<VoiceModerationDto>),
289 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
290 )
291)]
292pub async fn kick_participant(
293 State(service): State<ChatService>,
294 session: Session,
295 Path((room_id, user_id)): Path<(Uuid, Uuid)>,
296) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
297 let uid = require_uid(&session)?;
298 Ok(AppResponseData::ok(
299 service.kick_participant(uid, room_id, user_id).await?,
300 ))
301}
302
303#[utoipa::path(
304 post,
305 path = "/chat/rooms/{room_id}/voice/recording/start",
306 tag = "chat",
307 description = "录制预留接口;[livekit].recording_enabled=false 时返回 400。",
308 params(("room_id" = Uuid, Path, description = "room id")),
309 responses(
310 (status = 200, description = "recording started", body = AppResponseData<VoiceRecordingDto>),
311 (status = 400, description = "recording is not enabled", body = AppResponseData<NoData>),
312 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
313 )
314)]
315pub async fn start_recording(
316 State(service): State<ChatService>,
317 session: Session,
318 Path(room_id): Path<Uuid>,
319) -> Result<AppResponseData<VoiceRecordingDto>, ApiError> {
320 let uid = require_uid(&session)?;
321 Ok(AppResponseData::ok(
322 service.start_recording(uid, room_id).await?,
323 ))
324}
325
326#[utoipa::path(
327 post,
328 path = "/chat/rooms/{room_id}/voice/recording/stop",
329 tag = "chat",
330 description = "停止录制(预留接口)。",
331 params(("room_id" = Uuid, Path, description = "room id")),
332 responses(
333 (status = 200, description = "recording stopped", body = AppResponseData<VoiceRecordingDto>),
334 (status = 400, description = "recording is not enabled", body = AppResponseData<NoData>),
335 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
336 )
337)]
338pub async fn stop_recording(
339 State(service): State<ChatService>,
340 session: Session,
341 Path(room_id): Path<Uuid>,
342) -> Result<AppResponseData<VoiceRecordingDto>, ApiError> {
343 let uid = require_uid(&session)?;
344 Ok(AppResponseData::ok(
345 service.stop_recording(uid, room_id).await?,
346 ))
347}
348
349/// LiveKit webhook:无会话,用 api_key/api_secret 验签 Authorization 与 body sha256。
350#[utoipa::path(
351 post,
352 path = "/chat/livekit/webhook",
353 tag = "chat",
354 description = "LiveKit 事件回调(签名校验,无会话)。",
355 responses(
356 (status = 200, description = "ok", body = AppResponseData<NoData>),
357 (status = 401, description = "invalid signature", body = AppResponseData<NoData>)
358 )
359)]
360pub async fn livekit_webhook(
361 State(service): State<ChatService>,
362 headers: HeaderMap,
363 body: String,
364) -> Result<AppResponseData<NoData>, ApiError> {
365 let cfg = service.config().livekit.clone();
366 if !cfg.enabled {
367 return Ok(AppResponseData::empty());
368 }
369 let token = headers
370 .get("authorization")
371 .and_then(|value| value.to_str().ok())
372 .ok_or_else(ApiError::unauthorized)?;
373 let verifier = TokenVerifier::with_api_key(cfg.api_key.trim(), cfg.api_secret.trim());
374 let receiver = WebhookReceiver::new(verifier);
375 let event = receiver.receive(&body, token).map_err(|error| {
376 tracing::warn!(%error, "livekit webhook rejected");
377 ApiError::unauthorized()
378 })?;
379
380 let Some(room) = event.room.as_ref() else {
381 return Ok(AppResponseData::empty());
382 };
383 let Ok(room_id) = Uuid::parse_str(&room.name) else {
384 return Ok(AppResponseData::empty());
385 };
386
387 match event.event.as_str() {
388 "room_started" => {
389 service
390 .realtime
391 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
392 .await;
393 }
394 "room_finished" => {
395 if let Err(error) = crate::services::voice::clear_room(&service, room_id).await {
396 tracing::warn!(%error, %room_id, "voice room clear failed");
397 }
398 service
399 .realtime
400 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
401 .await;
402 }
403 "participant_joined" => {
404 if let Some(participant) = event.participant.as_ref()
405 && let Some(uid) = crate::services::voice::identity_to_uid(&participant.identity)
406 {
407 let names = crate::services::voice::display_names(&service, &[uid]).await;
408 let (display_name, avatar_url) = names
409 .get(&uid)
410 .cloned()
411 .unwrap_or_else(|| (participant.name.clone(), None));
412 let member = crate::services::voice::VoiceMember {
413 user_id: uid,
414 display_name,
415 avatar_url,
416 joined_at: participant.joined_at,
417 can_publish: participant
418 .permission
419 .as_ref()
420 .map(|p| p.can_publish)
421 .unwrap_or(true),
422 moderator_muted: false,
423 tracks: Vec::new(),
424 livekit_sid: Some(participant.sid.clone()),
425 };
426 if let Err(error) =
427 crate::services::voice::write_member(&service, room_id, &member).await
428 {
429 tracing::warn!(%error, %room_id, "voice member write failed");
430 }
431 }
432 service
433 .realtime
434 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
435 .await;
436 }
437 "participant_left" => {
438 if let Some(participant) = event.participant.as_ref()
439 && let Some(uid) = crate::services::voice::identity_to_uid(&participant.identity)
440 {
441 if let Err(error) =
442 crate::services::voice::remove_member(&service, room_id, uid).await
443 {
444 tracing::warn!(%error, %room_id, "voice member remove failed");
445 }
446 }
447 service
448 .realtime
449 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
450 .await;
451 service
452 .realtime
453 .publish_room(&crate::share::realtime::RoomEvent::VoiceHands { room_id })
454 .await;
455 }
456 "track_published" | "track_unpublished" => {
457 if let Some(participant) = event.participant.as_ref()
458 && let Some(uid) = crate::services::voice::identity_to_uid(&participant.identity)
459 {
460 let tracks = participant
461 .tracks
462 .iter()
463 .map(|t| crate::services::voice::VoiceTrackDto {
464 sid: t.sid.clone(),
465 kind: livekit_protocol::TrackType::try_from(t.r#type)
466 .map(|k| k.as_str_name().to_string())
467 .unwrap_or_else(|_| "UNKNOWN".to_string()),
468 source: livekit_protocol::TrackSource::try_from(t.source)
469 .map(|s| s.as_str_name().to_string())
470 .unwrap_or_else(|_| "UNKNOWN".to_string()),
471 muted: t.muted,
472 })
473 .collect();
474 if let Some(mut member) = crate::services::voice::read_members(&service, room_id)
475 .await
476 .unwrap_or_default()
477 .into_iter()
478 .find(|m| m.user_id == uid)
479 {
480 member.tracks = tracks;
481 let _ = crate::services::voice::write_member(&service, room_id, &member).await;
482 }
483 }
484 service
485 .realtime
486 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
487 .await;
488 }
489 _ => {}
490 }
491 Ok(AppResponseData::empty())
492}
Modified lib/chat/src/services/mod.rs View lib/chat/src/services/mod.rs
@@ -16,4 +16,5 @@
1616pub mod settings;
1717pub mod threads;
1818pub mod unfurl;
19pub mod voice;
1920pub mod webhooks;
Added lib/chat/src/services/voice.rs View lib/chat/src/services/voice.rs
@@ -0,0 +1,1057 @@
1use std::collections::HashMap;
2
3use livekit_api::access_token::{AccessToken, VideoGrants};
4use livekit_api::services::LiveKitApi;
5use livekit_api::services::room::{CreateRoomOptions, UpdateParticipantOptions};
6use livekit_protocol as proto;
7use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use utoipa::ToSchema;
11use uuid::Uuid;
12
13use crate::errors::ChatError;
14use crate::services::room_admin::active_room_role;
15use crate::services::service::ChatService;
16use crate::share::lookup::{lookup_space_by_name, require_room_readable};
17use crate::share::realtime::RoomEvent;
18
19/// 举手状态。审批通过后 approved,被收回后 revoked(可再次举手)。
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum HandState {
23 Pending,
24 Approved,
25 Denied,
26 Revoked,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
30pub struct HandEntry {
31 pub state: HandState,
32 pub requested_at: i64,
33 pub decided_by: Option<Uuid>,
34}
35
36#[derive(Debug, Clone, Serialize, ToSchema)]
37pub struct HandDto {
38 pub user_id: Uuid,
39 pub display_name: String,
40 pub avatar_url: Option<String>,
41 pub state: HandState,
42 pub requested_at: i64,
43}
44
45#[derive(Debug, Clone, Serialize, ToSchema)]
46pub struct VoiceConfigDto {
47 pub enabled: bool,
48 pub url: String,
49 pub recording_enabled: bool,
50}
51
52#[derive(Debug, Clone, Serialize, ToSchema)]
53pub struct VoiceTokenDto {
54 pub token: String,
55 pub url: String,
56 pub room_name: String,
57 pub expires_in_secs: u64,
58 pub can_publish: bool,
59 pub is_stage: bool,
60 pub hand_state: Option<HandState>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
64pub struct VoiceTrackDto {
65 pub sid: String,
66 pub kind: String,
67 pub source: String,
68 pub muted: bool,
69}
70
71#[derive(Debug, Clone, Serialize, ToSchema)]
72pub struct VoiceParticipantDto {
73 pub user_id: Uuid,
74 pub display_name: String,
75 pub avatar_url: Option<String>,
76 pub joined_at: i64,
77 pub can_publish: bool,
78 pub moderator_muted: bool,
79 pub speaking: bool,
80 pub tracks: Vec<VoiceTrackDto>,
81}
82
83#[derive(Debug, Clone, Serialize, ToSchema)]
84pub struct VoiceRoomStateDto {
85 pub room_id: Uuid,
86 pub count: u32,
87}
88
89#[derive(Debug, Clone, Serialize, ToSchema)]
90pub struct VoiceSpaceStatesDto {
91 pub rooms: Vec<VoiceRoomStateDto>,
92}
93
94#[derive(Debug, Clone, Serialize, ToSchema)]
95pub struct VoiceHandsDto {
96 pub hands: Vec<HandDto>,
97 pub can_moderate: bool,
98 pub my_state: Option<HandState>,
99}
100
101#[derive(Debug, Clone, Serialize, ToSchema)]
102pub struct VoiceModerationDto {
103 pub ok: bool,
104 pub action: String,
105 pub user_id: Uuid,
106}
107
108#[derive(Debug, Clone, Serialize, ToSchema)]
109pub struct VoiceRecordingDto {
110 pub egress_id: String,
111 pub active: bool,
112}
113
114/// Redis 中一位参与者的在场记录。
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct VoiceMember {
117 pub user_id: Uuid,
118 pub display_name: String,
119 pub avatar_url: Option<String>,
120 pub joined_at: i64,
121 pub can_publish: bool,
122 #[serde(default)]
123 pub moderator_muted: bool,
124 #[serde(default)]
125 pub tracks: Vec<VoiceTrackDto>,
126 pub livekit_sid: Option<String>,
127}
128
129pub fn voice_key(room_id: Uuid) -> String {
130 format!("chat:voice:{room_id}")
131}
132
133pub fn hands_key(room_id: Uuid) -> String {
134 format!("chat:voice:{room_id}:hands")
135}
136
137fn now_secs() -> i64 {
138 OffsetDateTime::now_utc().unix_timestamp()
139}
140
141fn livekit(service: &ChatService) -> Result<LiveKitApi, ChatError> {
142 let cfg = &service.config().livekit;
143 if !cfg.enabled {
144 return Err(ChatError::BadRequest("livekit is not enabled".to_string()));
145 }
146 Ok(LiveKitApi::with_api_key(
147 cfg.api_url.trim(),
148 cfg.api_key.trim(),
149 cfg.api_secret.trim(),
150 ))
151}
152
153/// 语音能力的全局限定:房间必须存在、必须是 Voice/Stage、调用者可读。
154pub async fn require_voice_room(
155 service: &ChatService,
156 room_id: Uuid,
157 uid: Uuid,
158) -> Result<models::room::rooms::Model, ChatError> {
159 let ctx = require_room_readable(service, room_id, uid).await?;
160 if !matches!(
161 ctx.room.kind,
162 models::room::rooms::RoomKind::Voice | models::room::rooms::RoomKind::Stage
163 ) {
164 return Err(ChatError::BadRequest("room is not a voice channel".to_string()));
165 }
166 Ok(ctx.room)
167}
168
169/// 调用者是否能主持(房间角色 >= Moderator,或空间 manage_channels)。
170pub async fn can_moderate_voice(
171 service: &ChatService,
172 room_id: Uuid,
173 uid: Uuid,
174) -> Result<bool, ChatError> {
175 if let Some(role) = active_room_role(service, room_id, uid).await?
176 && role >= models::room::room_members::RoomMemberRole::Moderator
177 {
178 return Ok(true);
179 }
180 let ctx = crate::share::lookup::lookup_room_context(service, room_id).await?;
181 match &ctx.space {
182 Some(space) => crate::share::authz::can_manage_space(service, space, uid).await,
183 None => Ok(false),
184 }
185}
186
187/// Stage 成员是否拥有发言权:房间角色 >= Moderator,或已有 approved 举手。
188pub async fn has_publish_grant(
189 service: &ChatService,
190 room_id: Uuid,
191 uid: Uuid,
192) -> Result<bool, ChatError> {
193 if can_moderate_voice(service, room_id, uid).await? {
194 return Ok(true);
195 }
196 Ok(matches!(
197 read_hand(service, room_id, uid).await?.map(|h| h.state),
198 Some(HandState::Approved)
199 ))
200}
201
202/// Grant strings must be the lowercase wire names the LiveKit server matches
203/// against (docs: camera | microphone | screen_share | screen_share_audio).
204/// The protobuf enum's `as_str_name()` is the uppercase constant name and does
205/// not match, which silently denies every publish with "no permission".
206fn publish_sources() -> Vec<String> {
207 vec![
208 "camera".to_string(),
209 "microphone".to_string(),
210 "screen_share".to_string(),
211 "screen_share_audio".to_string(),
212 ]
213}
214
215/// 参与者 id 的解析:identity 是 uid 字符串。
216pub fn identity_to_uid(identity: &str) -> Option<Uuid> {
217 Uuid::parse_str(identity).ok()
218}
219
220pub async fn display_names(
221 service: &ChatService,
222 uids: &[Uuid],
223) -> HashMap<Uuid, (String, Option<String>)> {
224 let mut map = HashMap::new();
225 let users = match service.load_user_briefs(uids).await {
226 Ok(users) => users,
227 Err(_) => return map,
228 };
229 for (uid, user) in users {
230 let avatar = if user.avatar_url.trim().is_empty() {
231 None
232 } else {
233 Some(user.avatar_url)
234 };
235 let name = if user.display_name.trim().is_empty() {
236 user.username
237 } else {
238 user.display_name
239 };
240 map.insert(uid, (name, avatar));
241 }
242 map
243}
244
245/// 把 Redis 里的一位成员写回(webhook 与对账共用)。
246pub async fn write_member(
247 service: &ChatService,
248 room_id: Uuid,
249 member: &VoiceMember,
250) -> Result<(), ChatError> {
251 let ttl = service.config().livekit.presence_ttl_secs.max(60);
252 let value = serde_json::to_string(member)?;
253 let mut conn = service.state.redis.connection_clone();
254 use export::RedisConnection;
255 let key = voice_key(room_id);
256 let field = member.user_id.to_string();
257 let result: Result<i64, redis::RedisError> = match &mut conn {
258 RedisConnection::Standalone(c) => {
259 let written: Result<i64, redis::RedisError> = redis::cmd("HSET")
260 .arg(&key)
261 .arg(&field)
262 .arg(&value)
263 .query_async(c)
264 .await;
265 if written.is_ok() {
266 let _: Result<i64, redis::RedisError> = redis::cmd("EXPIRE")
267 .arg(&key)
268 .arg(ttl)
269 .query_async(c)
270 .await;
271 }
272 written
273 }
274 RedisConnection::Cluster(c) => {
275 let written: Result<i64, redis::RedisError> = redis::cmd("HSET")
276 .arg(&key)
277 .arg(&field)
278 .arg(&value)
279 .query_async(c)
280 .await;
281 if written.is_ok() {
282 let _: Result<i64, redis::RedisError> = redis::cmd("EXPIRE")
283 .arg(&key)
284 .arg(ttl)
285 .query_async(c)
286 .await;
287 }
288 written
289 }
290 };
291 result.map(|_| ()).map_err(ChatError::from)
292}
293
294pub async fn remove_member(
295 service: &ChatService,
296 room_id: Uuid,
297 uid: Uuid,
298) -> Result<(), ChatError> {
299 let mut conn = service.state.redis.connection_clone();
300 use export::RedisConnection;
301 let key = voice_key(room_id);
302 let field = uid.to_string();
303 let _: Result<i64, redis::RedisError> = match &mut conn {
304 RedisConnection::Standalone(c) => {
305 redis::cmd("HDEL").arg(&key).arg(&field).query_async(c).await
306 }
307 RedisConnection::Cluster(c) => redis::cmd("HDEL").arg(&key).arg(&field).query_async(c).await,
308 };
309 let hkey = hands_key(room_id);
310 let _: Result<i64, redis::RedisError> = match &mut conn {
311 RedisConnection::Standalone(c) => {
312 redis::cmd("HDEL").arg(&hkey).arg(&field).query_async(c).await
313 }
314 RedisConnection::Cluster(c) => {
315 redis::cmd("HDEL").arg(&hkey).arg(&field).query_async(c).await
316 }
317 };
318 Ok(())
319}
320
321pub async fn clear_room(service: &ChatService, room_id: Uuid) -> Result<(), ChatError> {
322 let mut conn = service.state.redis.connection_clone();
323 use export::RedisConnection;
324 for key in [voice_key(room_id), hands_key(room_id)] {
325 let _: Result<i64, redis::RedisError> = match &mut conn {
326 RedisConnection::Standalone(c) => redis::cmd("DEL").arg(&key).query_async(c).await,
327 RedisConnection::Cluster(c) => redis::cmd("DEL").arg(&key).query_async(c).await,
328 };
329 }
330 Ok(())
331}
332
333/// 读取房间当前在 Redis 中的成员快照。
334pub async fn read_members(
335 service: &ChatService,
336 room_id: Uuid,
337) -> Result<Vec<VoiceMember>, ChatError> {
338 let mut conn = service.state.redis.connection_clone();
339 use export::RedisConnection;
340 let key = voice_key(room_id);
341 let raw: Result<HashMap<String, String>, redis::RedisError> = match &mut conn {
342 RedisConnection::Standalone(c) => redis::cmd("HGETALL").arg(&key).query_async(c).await,
343 RedisConnection::Cluster(c) => redis::cmd("HGETALL").arg(&key).query_async(c).await,
344 };
345 let raw = raw.unwrap_or_default();
346 let mut members: Vec<VoiceMember> = raw
347 .into_values()
348 .filter_map(|value| serde_json::from_str::<VoiceMember>(&value).ok())
349 .collect();
350 members.sort_by_key(|m| m.joined_at);
351 Ok(members)
352}
353
354pub async fn read_hand(
355 service: &ChatService,
356 room_id: Uuid,
357 uid: Uuid,
358) -> Result<Option<HandEntry>, ChatError> {
359 let mut conn = service.state.redis.connection_clone();
360 use export::RedisConnection;
361 let key = hands_key(room_id);
362 let field = uid.to_string();
363 let raw: Result<Option<String>, redis::RedisError> = match &mut conn {
364 RedisConnection::Standalone(c) => {
365 redis::cmd("HGET").arg(&key).arg(&field).query_async(c).await
366 }
367 RedisConnection::Cluster(c) => {
368 redis::cmd("HGET").arg(&key).arg(&field).query_async(c).await
369 }
370 };
371 Ok(raw.ok().flatten().and_then(|v| serde_json::from_str(&v).ok()))
372}
373
374pub async fn read_hands(
375 service: &ChatService,
376 room_id: Uuid,
377) -> Result<HashMap<Uuid, HandEntry>, ChatError> {
378 let mut conn = service.state.redis.connection_clone();
379 use export::RedisConnection;
380 let key = hands_key(room_id);
381 let raw: Result<HashMap<String, String>, redis::RedisError> = match &mut conn {
382 RedisConnection::Standalone(c) => redis::cmd("HGETALL").arg(&key).query_async(c).await,
383 RedisConnection::Cluster(c) => redis::cmd("HGETALL").arg(&key).query_async(c).await,
384 };
385 let mut map = HashMap::new();
386 for (field, value) in raw.unwrap_or_default() {
387 if let (Ok(uid), Ok(entry)) = (
388 Uuid::parse_str(&field),
389 serde_json::from_str::<HandEntry>(&value),
390 ) {
391 map.insert(uid, entry);
392 }
393 }
394 Ok(map)
395}
396
397pub async fn write_hand(
398 service: &ChatService,
399 room_id: Uuid,
400 uid: Uuid,
401 entry: &HandEntry,
402) -> Result<(), ChatError> {
403 let ttl = service.config().livekit.hand_ttl_secs.max(60);
404 let value = serde_json::to_string(entry)?;
405 let mut conn = service.state.redis.connection_clone();
406 use export::RedisConnection;
407 let key = hands_key(room_id);
408 let field = uid.to_string();
409 let result: Result<i64, redis::RedisError> = match &mut conn {
410 RedisConnection::Standalone(c) => {
411 let written: Result<i64, redis::RedisError> = redis::cmd("HSET")
412 .arg(&key)
413 .arg(&field)
414 .arg(&value)
415 .query_async(c)
416 .await;
417 if written.is_ok() {
418 let _: Result<i64, redis::RedisError> = redis::cmd("EXPIRE")
419 .arg(&key)
420 .arg(ttl)
421 .query_async(c)
422 .await;
423 }
424 written
425 }
426 RedisConnection::Cluster(c) => {
427 let written: Result<i64, redis::RedisError> = redis::cmd("HSET")
428 .arg(&key)
429 .arg(&field)
430 .arg(&value)
431 .query_async(c)
432 .await;
433 if written.is_ok() {
434 let _: Result<i64, redis::RedisError> = redis::cmd("EXPIRE")
435 .arg(&key)
436 .arg(ttl)
437 .query_async(c)
438 .await;
439 }
440 written
441 }
442 };
443 result.map(|_| ()).map_err(ChatError::from)
444}
445
446impl ChatService {
447 /// 前端搭接参数。
448 pub fn voice_config(&self) -> VoiceConfigDto {
449 let cfg = &self.config().livekit;
450 VoiceConfigDto {
451 enabled: cfg.enabled,
452 url: cfg.browser_url().to_string(),
453 recording_enabled: cfg.recording_enabled,
454 }
455 }
456
457 /// 为调用者签发 LiveKit 访问令牌。
458 pub async fn voice_token(
459 &self,
460 caller: Uuid,
461 room_id: Uuid,
462 ) -> Result<VoiceTokenDto, ChatError> {
463 let cfg = self.config().livekit.clone();
464 if !cfg.enabled {
465 return Err(ChatError::BadRequest("livekit is not enabled".to_string()));
466 }
467 let room = require_voice_room(self, room_id, caller).await?;
468 let is_stage = room.kind == models::room::rooms::RoomKind::Stage;
469 let can_publish = if is_stage {
470 has_publish_grant(self, room_id, caller).await?
471 } else {
472 true
473 };
474 let api = livekit(self)?;
475 let room_name = room_id.to_string();
476
477 let limit = room
478 .user_limit
479 .map(|v| v.max(1) as u32)
480 .unwrap_or(cfg.default_max_participants);
481 if let Ok(participants) = api.room().list_participants(&room_name).await {
482 let already_inside = participants
483 .iter()
484 .any(|p| identity_to_uid(&p.identity) == Some(caller));
485 if !already_inside && participants.len() as u32 >= limit {
486 return Err(ChatError::Conflict("voice room is full".to_string()));
487 }
488 }
489 let _ = api
490 .room()
491 .create_room(
492 &room_name,
493 CreateRoomOptions {
494 empty_timeout: cfg.empty_timeout_secs,
495 departure_timeout: cfg.departure_timeout_secs,
496 max_participants: limit,
497 ..Default::default()
498 },
499 )
500 .await;
501
502 let names = display_names(self, &[caller]).await;
503 let (display_name, _) = names
504 .get(&caller)
505 .cloned()
506 .unwrap_or_else(|| (caller.to_string(), None));
507
508 let grants = VideoGrants {
509 room_join: true,
510 room: room_name.clone(),
511 can_subscribe: true,
512 can_publish,
513 can_publish_data: true,
514 can_publish_sources: if can_publish {
515 publish_sources()
516 } else {
517 Vec::new()
518 },
519 ..Default::default()
520 };
521 let token = AccessToken::with_api_key(cfg.api_key.trim(), cfg.api_secret.trim())
522 .with_identity(&caller.to_string())
523 .with_name(&display_name)
524 .with_ttl(std::time::Duration::from_secs(cfg.token_ttl_secs))
525 .with_grants(grants)
526 .to_jwt()
527 .map_err(|error| ChatError::Internal(format!("livekit token: {error}")))?;
528
529 let hand_state = if is_stage {
530 read_hand(self, room_id, caller).await?.map(|h| h.state)
531 } else {
532 None
533 };
534
535 Ok(VoiceTokenDto {
536 token,
537 url: cfg.browser_url().to_string(),
538 room_name,
539 expires_in_secs: cfg.token_ttl_secs,
540 can_publish,
541 is_stage,
542 hand_state,
543 })
544 }
545
546 /// 以 LiveKit 为权威源对账,并回写 Redis。
547 pub async fn voice_participants(
548 &self,
549 caller: Uuid,
550 room_id: Uuid,
551 ) -> Result<Vec<VoiceParticipantDto>, ChatError> {
552 require_voice_room(self, room_id, caller).await?;
553 let api = livekit(self)?;
554 let room_name = room_id.to_string();
555 let participants = api
556 .room()
557 .list_participants(&room_name)
558 .await
559 .map_err(|error| ChatError::Internal(format!("livekit list participants: {error}")))?;
560
561 let uids: Vec<Uuid> = participants
562 .iter()
563 .filter_map(|p| identity_to_uid(&p.identity))
564 .collect();
565 let names = display_names(self, &uids).await;
566 let stored: HashMap<Uuid, VoiceMember> = read_members(self, room_id)
567 .await?
568 .into_iter()
569 .map(|m| (m.user_id, m))
570 .collect();
571
572 let mut out = Vec::with_capacity(participants.len());
573 for participant in participants {
574 let Some(uid) = identity_to_uid(&participant.identity) else {
575 continue;
576 };
577 let can_publish = participant
578 .permission
579 .as_ref()
580 .map(|p| p.can_publish)
581 .unwrap_or(true);
582 let moderator_muted = stored
583 .get(&uid)
584 .map(|m| m.moderator_muted)
585 .unwrap_or(false);
586 let tracks: Vec<VoiceTrackDto> = participant
587 .tracks
588 .iter()
589 .map(|t| VoiceTrackDto {
590 sid: t.sid.clone(),
591 kind: proto::TrackType::try_from(t.r#type)
592 .map(|k| k.as_str_name().to_string())
593 .unwrap_or_else(|_| "UNKNOWN".to_string()),
594 source: proto::TrackSource::try_from(t.source)
595 .map(|s| s.as_str_name().to_string())
596 .unwrap_or_else(|_| "UNKNOWN".to_string()),
597 muted: t.muted,
598 })
599 .collect();
600 let (display_name, avatar_url) = names
601 .get(&uid)
602 .cloned()
603 .unwrap_or_else(|| (participant.name.clone(), None));
604 let member = VoiceMember {
605 user_id: uid,
606 display_name: display_name.clone(),
607 avatar_url: avatar_url.clone(),
608 joined_at: participant.joined_at,
609 can_publish,
610 moderator_muted,
611 tracks: tracks.clone(),
612 livekit_sid: Some(participant.sid.clone()),
613 };
614 write_member(self, room_id, &member).await?;
615 out.push(VoiceParticipantDto {
616 user_id: uid,
617 display_name,
618 avatar_url,
619 joined_at: participant.joined_at,
620 can_publish,
621 moderator_muted,
622 speaking: false,
623 tracks,
624 });
625 }
626 out.sort_by_key(|p| p.joined_at);
627 Ok(out)
628 }
629
630 /// 侧栏用的整空间语音人数(纯 Redis 读,缺房间不报错)。
631 pub async fn voice_space_states(
632 &self,
633 caller: Uuid,
634 space_name: &str,
635 ) -> Result<VoiceSpaceStatesDto, ChatError> {
636 let space = lookup_space_by_name(self, space_name).await?;
637 crate::share::lookup::require_space_member(self, &space, caller).await?;
638 let rooms = models::room::rooms::Entity::find()
639 .filter(models::room::rooms::Column::SpaceId.eq(space.id))
640 .filter(models::room::rooms::Column::IsActive.eq(true))
641 .filter(
642 models::room::rooms::Column::Kind.is_in([
643 models::room::rooms::RoomKind::Voice,
644 models::room::rooms::RoomKind::Stage,
645 ]),
646 )
647 .all(self.db().read())
648 .await?;
649 let mut out = Vec::with_capacity(rooms.len());
650 for room in rooms {
651 let count = read_members(self, room.id).await?.len() as u32;
652 out.push(VoiceRoomStateDto {
653 room_id: room.id,
654 count,
655 });
656 }
657 Ok(VoiceSpaceStatesDto { rooms: out })
658 }
659
660 /// 举手。Stage 频道之外、已有发言权都不允许。
661 pub async fn raise_hand(&self, caller: Uuid, room_id: Uuid) -> Result<HandDto, ChatError> {
662 let room = require_voice_room(self, room_id, caller).await?;
663 if room.kind != models::room::rooms::RoomKind::Stage {
664 return Err(ChatError::BadRequest(
665 "hands are only available in stage channels".to_string(),
666 ));
667 }
668 if has_publish_grant(self, room_id, caller).await? {
669 return Err(ChatError::Conflict("already able to speak".to_string()));
670 }
671 let entry = HandEntry {
672 state: HandState::Pending,
673 requested_at: now_secs(),
674 decided_by: None,
675 };
676 write_hand(self, room_id, caller, &entry).await?;
677 self.realtime
678 .publish_room(&RoomEvent::VoiceHands { room_id })
679 .await;
680 let names = display_names(self, &[caller]).await;
681 let (display_name, avatar_url) = names
682 .get(&caller)
683 .cloned()
684 .unwrap_or_else(|| (caller.to_string(), None));
685 Ok(HandDto {
686 user_id: caller,
687 display_name,
688 avatar_url,
689 state: entry.state,
690 requested_at: entry.requested_at,
691 })
692 }
693
694 pub async fn cancel_hand(&self, caller: Uuid, room_id: Uuid) -> Result<(), ChatError> {
695 require_voice_room(self, room_id, caller).await?;
696 let existing = read_hand(self, room_id, caller).await?;
697 if matches!(existing.map(|h| h.state), Some(HandState::Approved)) {
698 return Err(ChatError::Conflict(
699 "an approved speaker must be revoked by a moderator".to_string(),
700 ));
701 }
702 let mut conn = self.state.redis.connection_clone();
703 use export::RedisConnection;
704 let key = hands_key(room_id);
705 let field = caller.to_string();
706 let _: Result<i64, redis::RedisError> = match &mut conn {
707 RedisConnection::Standalone(c) => {
708 redis::cmd("HDEL").arg(&key).arg(&field).query_async(c).await
709 }
710 RedisConnection::Cluster(c) => {
711 redis::cmd("HDEL").arg(&key).arg(&field).query_async(c).await
712 }
713 };
714 self.realtime
715 .publish_room(&RoomEvent::VoiceHands { room_id })
716 .await;
717 Ok(())
718 }
719
720 pub async fn list_hands(
721 &self,
722 caller: Uuid,
723 room_id: Uuid,
724 ) -> Result<VoiceHandsDto, ChatError> {
725 require_voice_room(self, room_id, caller).await?;
726 let can_moderate = can_moderate_voice(self, room_id, caller).await?;
727 let stored = read_hands(self, room_id).await?;
728 let my_state = stored.get(&caller).map(|h| h.state);
729 let uids: Vec<Uuid> = stored.keys().copied().collect();
730 let names = display_names(self, &uids).await;
731 let mut hands: Vec<HandDto> = stored
732 .into_iter()
733 .filter(|(_, entry)| can_moderate || matches!(entry.state, HandState::Approved))
734 .map(|(uid, entry)| {
735 let (display_name, avatar_url) = names
736 .get(&uid)
737 .cloned()
738 .unwrap_or_else(|| (uid.to_string(), None));
739 HandDto {
740 user_id: uid,
741 display_name,
742 avatar_url,
743 state: entry.state,
744 requested_at: entry.requested_at,
745 }
746 })
747 .collect();
748 hands.sort_by_key(|h| h.requested_at);
749 Ok(VoiceHandsDto {
750 hands,
751 can_moderate,
752 my_state,
753 })
754 }
755
756 async fn decide_hand(
757 &self,
758 caller: Uuid,
759 room_id: Uuid,
760 target: Uuid,
761 approve: bool,
762 ) -> Result<VoiceModerationDto, ChatError> {
763 require_voice_room(self, room_id, caller).await?;
764 if !can_moderate_voice(self, room_id, caller).await? {
765 return Err(ChatError::Forbidden);
766 }
767 let Some(existing) = read_hand(self, room_id, target).await? else {
768 return Err(ChatError::NotFound);
769 };
770 if !matches!(existing.state, HandState::Pending | HandState::Revoked) {
771 return Err(ChatError::Conflict("hand is not pending".to_string()));
772 }
773 let entry = HandEntry {
774 state: if approve {
775 HandState::Approved
776 } else {
777 HandState::Denied
778 },
779 requested_at: existing.requested_at,
780 decided_by: Some(caller),
781 };
782 write_hand(self, room_id, target, &entry).await?;
783 if approve {
784 let _ = self.apply_publish_permission(room_id, target, true).await;
785 }
786 self.realtime
787 .publish_room(&RoomEvent::VoiceHands { room_id })
788 .await;
789 let action = if approve {
790 "speak_granted"
791 } else {
792 "speak_denied"
793 };
794 self.realtime
795 .publish_room(&RoomEvent::VoiceModeration {
796 room_id,
797 user_id: target,
798 action: action.to_string(),
799 })
800 .await;
801 Ok(VoiceModerationDto {
802 ok: true,
803 action: action.to_string(),
804 user_id: target,
805 })
806 }
807
808 pub async fn approve_hand(
809 &self,
810 caller: Uuid,
811 room_id: Uuid,
812 target: Uuid,
813 ) -> Result<VoiceModerationDto, ChatError> {
814 self.decide_hand(caller, room_id, target, true).await
815 }
816
817 pub async fn deny_hand(
818 &self,
819 caller: Uuid,
820 room_id: Uuid,
821 target: Uuid,
822 ) -> Result<VoiceModerationDto, ChatError> {
823 self.decide_hand(caller, room_id, target, false).await
824 }
825
826 /// 收回发言权:翻转 can_publish 并静音已发布的麦克风。
827 pub async fn revoke_hand(
828 &self,
829 caller: Uuid,
830 room_id: Uuid,
831 target: Uuid,
832 ) -> Result<VoiceModerationDto, ChatError> {
833 require_voice_room(self, room_id, caller).await?;
834 if !can_moderate_voice(self, room_id, caller).await? {
835 return Err(ChatError::Forbidden);
836 }
837 let existing = read_hand(self, room_id, target).await?;
838 let requested_at = existing
839 .as_ref()
840 .map(|h| h.requested_at)
841 .unwrap_or_else(now_secs);
842 write_hand(
843 self,
844 room_id,
845 target,
846 &HandEntry {
847 state: HandState::Revoked,
848 requested_at,
849 decided_by: Some(caller),
850 },
851 )
852 .await?;
853 let _ = self.apply_publish_permission(room_id, target, false).await;
854 self.realtime
855 .publish_room(&RoomEvent::VoiceHands { room_id })
856 .await;
857 self.realtime
858 .publish_room(&RoomEvent::VoiceModeration {
859 room_id,
860 user_id: target,
861 action: "speak_revoked".to_string(),
862 })
863 .await;
864 Ok(VoiceModerationDto {
865 ok: true,
866 action: "speak_revoked".to_string(),
867 user_id: target,
868 })
869 }
870
871 /// 服务端翻转某人的发布权限(对 LiveKit 与 Redis 同时生效)。
872 pub async fn apply_publish_permission(
873 &self,
874 room_id: Uuid,
875 target: Uuid,
876 can_publish: bool,
877 ) -> Result<(), ChatError> {
878 let api = livekit(self)?;
879 let room_name = room_id.to_string();
880 let identity = target.to_string();
881 let sources: Vec<i32> = if can_publish {
882 vec![
883 proto::TrackSource::Camera as i32,
884 proto::TrackSource::Microphone as i32,
885 proto::TrackSource::ScreenShare as i32,
886 proto::TrackSource::ScreenShareAudio as i32,
887 ]
888 } else {
889 Vec::new()
890 };
891 let _ = api
892 .room()
893 .update_participant(
894 &room_name,
895 &identity,
896 UpdateParticipantOptions {
897 permission: Some(proto::ParticipantPermission {
898 can_subscribe: true,
899 can_publish,
900 can_publish_data: true,
901 can_publish_sources: sources,
902 ..Default::default()
903 }),
904 ..Default::default()
905 },
906 )
907 .await;
908 if let Some(mut member) = read_members(self, room_id)
909 .await?
910 .into_iter()
911 .find(|m| m.user_id == target)
912 {
913 member.can_publish = can_publish;
914 write_member(self, room_id, &member).await?;
915 }
916 Ok(())
917 }
918
919 /// 主持人强制静音/解除静音某人的麦克风。
920 pub async fn moderate_mute(
921 &self,
922 caller: Uuid,
923 room_id: Uuid,
924 target: Uuid,
925 muted: bool,
926 ) -> Result<VoiceModerationDto, ChatError> {
927 require_voice_room(self, room_id, caller).await?;
928 if !can_moderate_voice(self, room_id, caller).await? {
929 return Err(ChatError::Forbidden);
930 }
931 let api = livekit(self)?;
932 let room_name = room_id.to_string();
933 let identity = target.to_string();
934 if muted {
935 let _ = api
936 .room()
937 .update_participant(
938 &room_name,
939 &identity,
940 UpdateParticipantOptions {
941 permission: Some(proto::ParticipantPermission {
942 can_subscribe: true,
943 can_publish: false,
944 can_publish_data: true,
945 can_publish_sources: Vec::new(),
946 ..Default::default()
947 }),
948 ..Default::default()
949 },
950 )
951 .await;
952 if let Ok(participants) = api.room().list_participants(&room_name).await
953 && let Some(participant) = participants.iter().find(|p| p.identity == identity)
954 {
955 for track in &participant.tracks {
956 if track.source == proto::TrackSource::Microphone as i32 && !track.muted {
957 let _ = api
958 .room()
959 .mute_published_track(&room_name, &identity, &track.sid, true)
960 .await;
961 }
962 }
963 }
964 } else {
965 let _ = self.apply_publish_permission(room_id, target, true).await;
966 }
967 if let Some(mut member) = read_members(self, room_id)
968 .await?
969 .into_iter()
970 .find(|m| m.user_id == target)
971 {
972 member.moderator_muted = muted;
973 write_member(self, room_id, &member).await?;
974 }
975 let action = if muted { "muted" } else { "unmuted" }.to_string();
976 self.realtime
977 .publish_room(&RoomEvent::VoiceModeration {
978 room_id,
979 user_id: target,
980 action: action.clone(),
981 })
982 .await;
983 Ok(VoiceModerationDto {
984 ok: true,
985 action,
986 user_id: target,
987 })
988 }
989
990 /// 强制移出通话(可再次加入)。
991 pub async fn kick_participant(
992 &self,
993 caller: Uuid,
994 room_id: Uuid,
995 target: Uuid,
996 ) -> Result<VoiceModerationDto, ChatError> {
997 require_voice_room(self, room_id, caller).await?;
998 if !can_moderate_voice(self, room_id, caller).await? {
999 return Err(ChatError::Forbidden);
1000 }
1001 if caller == target {
1002 return Err(ChatError::BadRequest("cannot remove yourself".to_string()));
1003 }
1004 let api = livekit(self)?;
1005 let room_name = room_id.to_string();
1006 let identity = target.to_string();
1007 let _ = api.room().remove_participant(&room_name, &identity).await;
1008 remove_member(self, room_id, target).await?;
1009 self.realtime
1010 .publish_room(&RoomEvent::VoiceModeration {
1011 room_id,
1012 user_id: target,
1013 action: "removed".to_string(),
1014 })
1015 .await;
1016 Ok(VoiceModerationDto {
1017 ok: true,
1018 action: "removed".to_string(),
1019 user_id: target,
1020 })
1021 }
1022
1023 /// 录制预留:需要 egress 组件,本次只做接口占位。
1024 pub async fn start_recording(
1025 &self,
1026 caller: Uuid,
1027 room_id: Uuid,
1028 ) -> Result<VoiceRecordingDto, ChatError> {
1029 require_voice_room(self, room_id, caller).await?;
1030 if !can_moderate_voice(self, room_id, caller).await? {
1031 return Err(ChatError::Forbidden);
1032 }
1033 if !self.config().livekit.recording_enabled {
1034 return Err(ChatError::BadRequest("recording is not enabled".to_string()));
1035 }
1036 Err(ChatError::BadRequest(
1037 "recording requires the livekit egress component".to_string(),
1038 ))
1039 }
1040
1041 pub async fn stop_recording(
1042 &self,
1043 caller: Uuid,
1044 room_id: Uuid,
1045 ) -> Result<VoiceRecordingDto, ChatError> {
1046 require_voice_room(self, room_id, caller).await?;
1047 if !can_moderate_voice(self, room_id, caller).await? {
1048 return Err(ChatError::Forbidden);
1049 }
1050 if !self.config().livekit.recording_enabled {
1051 return Err(ChatError::BadRequest("recording is not enabled".to_string()));
1052 }
1053 Err(ChatError::BadRequest(
1054 "recording requires the livekit egress component".to_string(),
1055 ))
1056 }
1057}
Modified lib/chat/src/share/realtime.rs View lib/chat/src/share/realtime.rs
@@ -71,6 +71,20 @@
7171 room_id: Uuid,
7272 thread_id: Uuid,
7373 },
74 /// 语音频道在场集合变化(webhook 加入/离开、对账回写)。
75 VoicePresence {
76 room_id: Uuid,
77 },
78 /// 举手集合变化(举手、审批、收回、离开)。
79 VoiceHands {
80 room_id: Uuid,
81 },
82 /// 主持人动作(静音、解除静音、移出、授权)。
83 VoiceModeration {
84 room_id: Uuid,
85 user_id: Uuid,
86 action: String,
87 },
7488}
7589
7690impl RoomEvent {
@@ -86,7 +100,10 @@
86100 | Self::RoomUpdated { room_id, .. }
87101 | Self::MemberJoined { room_id, .. }
88102 | Self::MemberLeft { room_id, .. }
89 | Self::ThreadUpdated { room_id, .. } => *room_id,
103 | Self::ThreadUpdated { room_id, .. }
104 | Self::VoicePresence { room_id }
105 | Self::VoiceHands { room_id }
106 | Self::VoiceModeration { room_id, .. } => *room_id,
90107 }
91108 }
92109
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -88,6 +88,21 @@
8888 crate::handlers::webhooks::list_outbound_webhooks,
8989 crate::handlers::webhooks::update_outbound_webhook,
9090 crate::handlers::webhooks::delete_outbound_webhook,
91 crate::handlers::voice::voice_config,
92 crate::handlers::voice::voice_token,
93 crate::handlers::voice::voice_participants,
94 crate::handlers::voice::voice_space_states,
95 crate::handlers::voice::list_hands,
96 crate::handlers::voice::raise_hand,
97 crate::handlers::voice::cancel_hand,
98 crate::handlers::voice::approve_hand,
99 crate::handlers::voice::deny_hand,
100 crate::handlers::voice::revoke_hand,
101 crate::handlers::voice::moderate_mute,
102 crate::handlers::voice::kick_participant,
103 crate::handlers::voice::start_recording,
104 crate::handlers::voice::stop_recording,
105 crate::handlers::voice::livekit_webhook,
91106 ),
92107 components(schemas(
93108 export::pagination::PageQuery,
@@ -166,6 +181,17 @@
166181 crate::handlers::drafts::DraftQuery,
167182 crate::services::presence::PresenceDto,
168183 crate::services::presence::SetPresenceCmd,
184 crate::services::voice::VoiceConfigDto,
185 crate::services::voice::VoiceTokenDto,
186 crate::services::voice::VoiceTrackDto,
187 crate::services::voice::VoiceParticipantDto,
188 crate::services::voice::VoiceRoomStateDto,
189 crate::services::voice::VoiceSpaceStatesDto,
190 crate::services::voice::VoiceHandsDto,
191 crate::services::voice::VoiceModerationDto,
192 crate::services::voice::VoiceRecordingDto,
193 crate::services::voice::HandDto,
194 crate::services::voice::HandState,
169195 crate::services::search::SearchHitDto,
170196 crate::services::search::SearchPage,
171197 crate::services::search::SearchQuery,
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -30,6 +30,11 @@
3030 create_thread, follow_thread, get_thread, list_my_threads, list_threads, unfollow_thread,
3131 update_thread,
3232};
33use crate::handlers::voice::{
34 approve_hand, cancel_hand, deny_hand, kick_participant, list_hands, livekit_webhook,
35 moderate_mute, raise_hand, revoke_hand, start_recording, stop_recording, voice_config,
36 voice_participants, voice_space_states, voice_token,
37};
3338use crate::handlers::webhooks::{create_webhook, delete_webhook, list_webhooks, post_via_webhook};
3439use crate::services::service::ChatService;
3540use crate::share::guard::chat_guard;
@@ -235,12 +240,63 @@
235240 axum::routing::patch(crate::handlers::webhooks::update_outbound_webhook)
236241 .delete(crate::handlers::webhooks::delete_outbound_webhook),
237242 )
243 .route("/chat/voice/config", get(voice_config))
244 .route(
245 "/chat/rooms/{room_id}/voice/token",
246 post(voice_token),
247 )
248 .route(
249 "/chat/rooms/{room_id}/voice/participants",
250 get(voice_participants),
251 )
252 .route(
253 "/chat/rooms/{room_id}/voice/participants/{user_id}",
254 delete(kick_participant),
255 )
256 .route(
257 "/chat/rooms/{room_id}/voice/participants/{user_id}/mute",
258 post(moderate_mute),
259 )
260 .route(
261 "/chat/spaces/{space_name}/voice-states",
262 get(voice_space_states),
263 )
264 .route(
265 "/chat/rooms/{room_id}/voice/hands",
266 get(list_hands).post(raise_hand),
267 )
268 .route(
269 "/chat/rooms/{room_id}/voice/hands/@me",
270 delete(cancel_hand),
271 )
272 .route(
273 "/chat/rooms/{room_id}/voice/hands/{user_id}/approve",
274 post(approve_hand),
275 )
276 .route(
277 "/chat/rooms/{room_id}/voice/hands/{user_id}/deny",
278 post(deny_hand),
279 )
280 .route(
281 "/chat/rooms/{room_id}/voice/hands/{user_id}/revoke",
282 post(revoke_hand),
283 )
284 .route(
285 "/chat/rooms/{room_id}/voice/recording/start",
286 post(start_recording),
287 )
288 .route(
289 "/chat/rooms/{room_id}/voice/recording/stop",
290 post(stop_recording),
291 )
238292 .route_layer(axum::middleware::from_fn_with_state(
239293 service.clone(),
240294 chat_guard,
241295 ));
242296
243 let open = Router::new().route("/chat/hooks/{token}", post(post_via_webhook));
297 let open = Router::new()
298 .route("/chat/hooks/{token}", post(post_via_webhook))
299 .route("/chat/livekit/webhook", post(livekit_webhook));
244300
245301 guarded.merge(open).merge(space_room_routes)
246302}
Modified lib/chat/Cargo.toml View lib/chat/Cargo.toml
@@ -15,6 +15,8 @@
1515sea-orm = { workspace = true }
1616redis = { workspace = true }
1717reqwest = { workspace = true }
18livekit-api = { workspace = true }
19livekit-protocol = { workspace = true }
1820serde = { workspace = true, features = ["derive"] }
1921serde_json = { workspace = true }
2022utoipa = { workspace = true }
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -15,6 +15,7 @@
1515pub mod im;
1616pub mod import;
1717pub mod issues;
18pub mod livekit;
1819pub mod pow;
1920pub mod quota;
2021pub mod redis;
@@ -50,6 +51,7 @@
5051use crate::im::ImServerConfig;
5152use crate::import::ImportConfig;
5253use crate::issues::IssueConfig;
54use crate::livekit::LiveKitConfig;
5355use crate::pow::PowConfig;
5456use crate::redis::RedisConfig;
5557use crate::runner::RunnerConfig;
@@ -80,6 +82,7 @@
8082 pub quota: quota::QuotaConfig,
8183 pub space: SpaceConfig,
8284 pub issues: IssueConfig,
85 pub livekit: LiveKitConfig,
8386 pub git: GitConfig,
8487 pub email: EmailConfig,
8588 pub email_queue: EmailQueueConfig,
@@ -123,6 +126,7 @@
123126 self.webhook.is_valid().map_err(ConfigError::Validation)?;
124127 self.ai.validate().map_err(ConfigError::Validation)?;
125128 self.aihost.is_valid().map_err(ConfigError::Validation)?;
129 self.livekit.is_valid().map_err(ConfigError::Validation)?;
126130 Ok(())
127131 }
128132}
Added lib/config/src/livekit.rs View lib/config/src/livekit.rs
@@ -0,0 +1,92 @@
1use serde::Deserialize;
2
3/// LiveKit WebRTC 服务配置。`url` 下发给浏览器,`api_url` 供服务端签发
4/// token / 调用 RoomService,两者在同一部署里通常指向同一进程。
5#[derive(Clone, Debug, Deserialize)]
6#[serde(default)]
7pub struct LiveKitConfig {
8 pub enabled: bool,
9 /// 浏览器侧 WebSocket 地址(ws:// 或 wss://)。
10 pub url: String,
11 /// 服务端侧 HTTP 地址(http:// 或 https://)。
12 pub api_url: String,
13 pub api_key: String,
14 pub api_secret: String,
15 pub token_ttl_secs: u64,
16 pub empty_timeout_secs: u32,
17 pub departure_timeout_secs: u32,
18 pub default_max_participants: u32,
19 /// Redis 中参与者在场记录的存活时间;每次 webhook 事件续期。
20 pub presence_ttl_secs: u64,
21 /// 举手状态的存活时间。
22 pub hand_ttl_secs: u64,
23 /// 录制/推流能力开关;为 false 时相关接口直接拒绝。
24 pub recording_enabled: bool,
25}
26
27impl Default for LiveKitConfig {
28 fn default() -> Self {
29 Self {
30 enabled: false,
31 url: String::new(),
32 api_url: String::new(),
33 api_key: String::new(),
34 api_secret: String::new(),
35 token_ttl_secs: 7200,
36 empty_timeout_secs: 300,
37 departure_timeout_secs: 20,
38 default_max_participants: 50,
39 presence_ttl_secs: 43_200,
40 hand_ttl_secs: 3_600,
41 recording_enabled: false,
42 }
43 }
44}
45
46fn scheme_ok(value: &str, allowed: &[&str]) -> bool {
47 match value.split_once("://") {
48 Some((scheme, rest)) => !rest.is_empty() && allowed.contains(&scheme.to_ascii_lowercase().as_str()),
49 None => false,
50 }
51}
52
53impl LiveKitConfig {
54 pub fn is_valid(&self) -> Result<(), String> {
55 if !self.enabled {
56 return Ok(());
57 }
58 if self.url.trim().is_empty() {
59 return Err("livekit.url must not be empty when livekit is enabled".to_string());
60 }
61 if self.api_url.trim().is_empty() {
62 return Err("livekit.api_url must not be empty when livekit is enabled".to_string());
63 }
64 if !scheme_ok(self.url.trim(), &["ws", "wss"]) {
65 return Err("livekit.url must be a ws:// or wss:// URL".to_string());
66 }
67 if !scheme_ok(self.api_url.trim(), &["http", "https"]) {
68 return Err("livekit.api_url must be an http:// or https:// URL".to_string());
69 }
70 if self.api_key.trim().is_empty() {
71 return Err("livekit.api_key must not be empty when livekit is enabled".to_string());
72 }
73 if self.api_secret.trim().is_empty() {
74 return Err("livekit.api_secret must not be empty when livekit is enabled".to_string());
75 }
76 if self.token_ttl_secs == 0
77 || self.empty_timeout_secs == 0
78 || self.departure_timeout_secs == 0
79 || self.default_max_participants == 0
80 || self.presence_ttl_secs == 0
81 || self.hand_ttl_secs == 0
82 {
83 return Err("livekit numeric settings must be greater than zero".to_string());
84 }
85 Ok(())
86 }
87
88 /// 前端可安全暴露的字段(绝不包含 api_secret)。
89 pub fn browser_url(&self) -> &str {
90 self.url.trim()
91 }
92}
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -19,6 +19,7 @@
1919use crate::im::ImServerConfig;
2020use crate::import::ImportConfig;
2121use crate::issues::IssueConfig;
22use crate::livekit::LiveKitConfig;
2223use crate::pow::PowConfig;
2324use crate::quota::QuotaConfig;
2425use crate::redis::RedisConfig;
@@ -211,6 +212,7 @@
211212 pub search: SearchConfig,
212213 pub import: ImportConfig,
213214 pub ai: AiConfig,
215 pub livekit: LiveKitConfig,
214216}
215217
216218impl ApihostConfig {
@@ -236,6 +238,7 @@
236238 search: self.search,
237239 import: self.import,
238240 ai: self.ai,
241 livekit: self.livekit,
239242 ..Default::default()
240243 }
241244 }
@@ -255,7 +258,8 @@
255258 self.search.validate()?;
256259 self.import.validate()?;
257260 self.apihost.is_valid()?;
258 self.ai.validate()
261 self.ai.validate()?;
262 self.livekit.is_valid()
259263 }
260264}
261265
Modified lib/space/src/services/rooms.rs View lib/space/src/services/rooms.rs
@@ -111,10 +111,15 @@
111111 }
112112 if !matches!(
113113 cmd.kind,
114 RoomKind::GuildRoom | RoomKind::Text | RoomKind::Category | RoomKind::News
114 RoomKind::GuildRoom
115 | RoomKind::Text
116 | RoomKind::Category
117 | RoomKind::News
118 | RoomKind::Voice
119 | RoomKind::Stage
115120 ) {
116121 return Err(SpaceError::BadRequest(
117 "unsupported room kind: only guild_room, text, category and news can be created"
122 "unsupported room kind: only guild_room, text, category, news, voice and stage can be created"
118123 .to_string(),
119124 ));
120125 }
Modified scripts/dev.ps1 View scripts/dev.ps1
@@ -56,7 +56,8 @@
5656 @{ Name = 'postgresql'; Port = 5432 },
5757 @{ Name = 'redis8'; Port = 6379 },
5858 @{ Name = 'rustfs'; Port = 9000 },
59 @{ Name = 'meilisearch'; Port = 7700 }
59 @{ Name = 'meilisearch'; Port = 7700 },
60 @{ Name = 'livekit'; Port = 7880 }
6061)
6162
6263function Write-Info { param([string]$Message) Write-Host $Message }
Modified web/src/app/bootstrap.tsx View web/src/app/bootstrap.tsx
@@ -1,13 +1,15 @@
1import type { ReactNode } from 'react'
1import { useEffect, type ReactNode } from 'react'
22import { alovaInstance } from '@/shared/api'
33import { ThemeProvider } from '@/shared/ui'
4import { storedVoiceSession, voiceSession } from '@/shared/voice'
5import { requestVoiceToken } from '@/entities/chat'
46import { ApiErrorGate } from './api-error-gate'
57
68/**
79 * App-wide providers (Redux / QueryClient / theme / auth context, ...).
810 *
911 * Alova is provider-less, so the only wiring here is instance configuration.
10 * Keeping provider composition here lets `app/index.tsx` stay pure bootstrap.
12 * Keeping provider composition here lets \`app/index.tsx\` stay pure bootstrap.
1113 */
1214
1315/**
@@ -19,13 +21,64 @@
1921 * requests are still deduplicated by shareRequest.
2022 */
2123alovaInstance.options.cacheFor = { GET: 0 }
24
25let restoreStarted = false
26
27/**
28 * Restores an in-progress call after a page refresh.
29 *
30 * The connection itself cannot survive a reload, so the room id is persisted in
31 * sessionStorage and the tab re-joins silently — **muted**, because a browser
32 * will block remote audio until a gesture anyway and rejoining with an open
33 * microphone would be worse. The call bar offers "恢复声音" once it is back.
34 *
35 * It lives in the app layer on purpose: shared/voice must not import the
36 * entities layer, so the token request is wired here where both are in scope.
37 */
38function VoiceRestore() {
39 useEffect(() => {
40 // StrictMode double-invokes effects in development. The restore is a
41 // module-level singleton action (one call per page load), so it is guarded
42 // here rather than by a ref: a second invocation would tear down and
43 // rebuild the very connection the first one just established.
44 if (restoreStarted) return
45 restoreStarted = true
46 const stored = storedVoiceSession()
47 if (!stored) return
48 void (async () => {
49 try {
50 const token = await requestVoiceToken(stored.roomId)
51 await voiceSession.join({
52 roomId: stored.roomId,
53 roomLabel: stored.roomLabel,
54 spaceName: stored.spaceName,
55 url: token.url,
56 token: token.token,
57 canPublish: token.can_publish,
58 isStage: token.is_stage,
59 // Refresh restores the call but never reopens the microphone.
60 startWithMic: false,
61 })
62 } catch {
63 // A stale room (deleted channel, revoked access) simply does not
64 // restore; the stored id is cleared so the next load stays quiet.
65 voiceSession.leave()
66 }
67 })()
68 }, [])
69 return null
70}
71
2272export function AppProviders({ children }: { children: ReactNode }) {
2373 // The outage gate is deliberately above the router: it is a property of the
2474 // transport, not of any single route, and it must be able to swallow the
2575 // whole tree (auth screens included) without touching the URL.
2676 return (
2777 <ApiErrorGate>
28 <ThemeProvider>{children}</ThemeProvider>
78 <ThemeProvider>
79 <VoiceRestore />
80 {children}
81 </ThemeProvider>
2982 </ApiErrorGate>
3083 )
3184}
Added web/src/entities/chat/model/voice-api.ts View web/src/entities/chat/model/voice-api.ts
@@ -0,0 +1,89 @@
1/**
2 * Voice REST surface (LiveKit). Wraps the generated Alova definitions so
3 * consumers deal in domain types instead of the envelope namespace; every
4 * call resolves through \`unwrapApi\`, so failures surface as \`ApiFailure\`.
5 */
6import Apis from '@/shared/api'
7import { unwrapApi } from '@/shared/lib'
8import type {
9 AppResponseData_HandDto,
10 AppResponseData_Vec_VoiceParticipantDto,
11 AppResponseData_VoiceConfigDto,
12 AppResponseData_VoiceHandsDto,
13 AppResponseData_VoiceModerationDto,
14 AppResponseData_VoiceSpaceStatesDto,
15 AppResponseData_VoiceTokenDto,
16 HandState,
17} from '@/shared/api'
18
19export type VoiceConfigDto = NonNullable<AppResponseData_VoiceConfigDto['data']>
20export type VoiceToken = NonNullable<AppResponseData_VoiceTokenDto['data']>
21export type VoiceHand = NonNullable<AppResponseData_HandDto['data']>
22export type VoiceParticipant = NonNullable<AppResponseData_Vec_VoiceParticipantDto['data']>[number]
23export type VoiceHands = NonNullable<AppResponseData_VoiceHandsDto['data']>
24export type VoiceModeration = NonNullable<AppResponseData_VoiceModerationDto['data']>
25export type VoiceSpaceStates = NonNullable<AppResponseData_VoiceSpaceStatesDto['data']>
26export type VoiceHandState = HandState
27
28export function getVoiceConfig(): Promise<VoiceConfigDto> {
29 return unwrapApi(Apis.chat.voice_config({}))
30}
31
32export function requestVoiceToken(roomId: string): Promise<VoiceToken> {
33 return unwrapApi(Apis.chat.voice_token({ pathParams: { room_id: roomId } }))
34}
35
36export function listVoiceParticipants(roomId: string): Promise<VoiceParticipant[]> {
37 return unwrapApi(Apis.chat.voice_participants({ pathParams: { room_id: roomId } }))
38}
39
40export function getVoiceSpaceStates(spaceName: string): Promise<VoiceSpaceStates> {
41 return unwrapApi(Apis.chat.voice_space_states({ pathParams: { space_name: spaceName } }))
42}
43
44export function listVoiceHands(roomId: string): Promise<VoiceHands> {
45 return unwrapApi(Apis.chat.list_hands({ pathParams: { room_id: roomId } }))
46}
47
48export function raiseVoiceHand(roomId: string): Promise<VoiceHand> {
49 return unwrapApi(Apis.chat.raise_hand({ pathParams: { room_id: roomId } }))
50}
51
52export function cancelVoiceHand(roomId: string): Promise<void> {
53 return unwrapApi(Apis.chat.cancel_hand({ pathParams: { room_id: roomId } })).then(
54 () => undefined,
55 )
56}
57
58export function approveVoiceHand(roomId: string, userId: string): Promise<VoiceModeration> {
59 return unwrapApi(
60 Apis.chat.approve_hand({ pathParams: { room_id: roomId, user_id: userId } }),
61 )
62}
63
64export function denyVoiceHand(roomId: string, userId: string): Promise<VoiceModeration> {
65 return unwrapApi(Apis.chat.deny_hand({ pathParams: { room_id: roomId, user_id: userId } }))
66}
67
68export function revokeVoiceHand(roomId: string, userId: string): Promise<VoiceModeration> {
69 return unwrapApi(Apis.chat.revoke_hand({ pathParams: { room_id: roomId, user_id: userId } }))
70}
71
72export function moderateVoiceMute(
73 roomId: string,
74 userId: string,
75 muted: boolean,
76): Promise<VoiceModeration> {
77 return unwrapApi(
78 Apis.chat.moderate_mute({
79 pathParams: { room_id: roomId, user_id: userId },
80 params: { muted },
81 }),
82 )
83}
84
85export function kickVoiceParticipant(roomId: string, userId: string): Promise<VoiceModeration> {
86 return unwrapApi(
87 Apis.chat.kick_participant({ pathParams: { room_id: roomId, user_id: userId } }),
88 )
89}
Modified web/src/entities/chat/index.ts View web/src/entities/chat/index.ts
@@ -6,3 +6,28 @@
66export * from './model/chat'
77export * from './model/meta'
88export * from './model/api'
9
10export {
11 approveVoiceHand,
12 cancelVoiceHand,
13 denyVoiceHand,
14 getVoiceConfig,
15 getVoiceSpaceStates,
16 kickVoiceParticipant,
17 listVoiceHands,
18 listVoiceParticipants,
19 moderateVoiceMute,
20 raiseVoiceHand,
21 requestVoiceToken,
22 revokeVoiceHand,
23} from './model/voice-api'
24export type {
25 VoiceConfigDto,
26 VoiceHand,
27 VoiceHands,
28 VoiceHandState,
29 VoiceModeration,
30 VoiceParticipant,
31 VoiceSpaceStates,
32 VoiceToken,
33} from './model/voice-api'
Added web/src/features/chat/ui/voice/voice-call-bar.tsx View web/src/features/chat/ui/voice/voice-call-bar.tsx
@@ -0,0 +1,230 @@
1/**
2 * The persistent call bar. It renders in every workspace mode (chat, board,
3 * repository) because the call outlives the route, and it is the only place a
4 * browser-blocked remote-audio stream can be resumed with a user gesture.
5 *
6 * It floats and is draggable: the composer, the file tree and the board all
7 * want the bottom of the screen, so the bar stays where the user parked it.
8 */
9import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
10import { Link } from 'react-router-dom'
11import {
12 AlertCircle,
13 GripVertical,
14 Loader2,
15 Mic,
16 MicOff,
17 MonitorUp,
18 PhoneOff,
19 Video,
20 VideoOff,
21 Volume2,
22} from 'lucide-react'
23import { useVoiceSession, voiceSession } from '@/shared/voice'
24
25const POSITION_KEY = 'gitdata.voice.callbar.position'
26const VIEWPORT_MARGIN = 12
27
28interface Point {
29 x: number
30 y: number
31}
32
33function readStoredPosition(): Point | null {
34 try {
35 const raw = localStorage.getItem(POSITION_KEY)
36 if (!raw) return null
37 const parsed = JSON.parse(raw) as Partial<Point>
38 return typeof parsed.x === 'number' && typeof parsed.y === 'number'
39 ? { x: parsed.x, y: parsed.y }
40 : null
41 } catch {
42 return null
43 }
44}
45
46/** Keep a parked bar reachable no matter how small the window becomes. */
47function clampToViewport(point: Point, el: HTMLDivElement | null): Point {
48 const width = el?.offsetWidth ?? 320
49 const height = el?.offsetHeight ?? 46
50 return {
51 x: Math.min(Math.max(VIEWPORT_MARGIN, point.x), Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)),
52 y: Math.min(Math.max(VIEWPORT_MARGIN, point.y), Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)),
53 }
54}
55
56function isInteractive(target: EventTarget | null): boolean {
57 return target instanceof Element && target.closest('button, a') !== null
58}
59
60export function VoiceCallBar() {
61 const session = useVoiceSession()
62 const barRef = useRef<HTMLDivElement | null>(null)
63 const dragRef = useRef<{ offsetX: number; offsetY: number } | null>(null)
64 const [position, setPosition] = useState<Point | null>(readStoredPosition)
65 const positionRef = useRef<Point | null>(position)
66 const [dragging, setDragging] = useState(false)
67
68 useEffect(() => {
69 const onResize = () =>
70 setPosition(current => (current ? clampToViewport(current, barRef.current) : current))
71 window.addEventListener('resize', onResize)
72 return () => window.removeEventListener('resize', onResize)
73 }, [])
74
75 const moveTo = (next: Point) => {
76 const clamped = clampToViewport(next, barRef.current)
77 positionRef.current = clamped
78 setPosition(clamped)
79 }
80
81 const resetPosition = () => {
82 positionRef.current = null
83 setPosition(null)
84 try {
85 localStorage.removeItem(POSITION_KEY)
86 } catch {
87 // Storage is an convenience here, never a requirement.
88 }
89 }
90
91 const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
92 const el = barRef.current
93 if (!el || event.button !== 0 || isInteractive(event.target)) return
94 const rect = el.getBoundingClientRect()
95 dragRef.current = { offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top }
96 el.setPointerCapture(event.pointerId)
97 setDragging(true)
98 }
99
100 const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
101 const drag = dragRef.current
102 if (!drag) return
103 moveTo({ x: event.clientX - drag.offsetX, y: event.clientY - drag.offsetY })
104 }
105
106 const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
107 if (!dragRef.current) return
108 dragRef.current = null
109 setDragging(false)
110 try {
111 barRef.current?.releasePointerCapture(event.pointerId)
112 } catch {
113 // The pointer may already be gone; nothing to release.
114 }
115 const parked = positionRef.current
116 if (!parked) return
117 try {
118 localStorage.setItem(POSITION_KEY, JSON.stringify(parked))
119 } catch {
120 // Same: a full storage quota must not break the call.
121 }
122 }
123
124 const active = session.roomId !== null && session.status !== 'idle'
125 if (!active) return null
126
127 // The label and the route are captured at join time; a call keeps working
128 // from any page, so the bar never depends on the current route params.
129 const roomLabel = session.roomLabel ?? '语音频道'
130 const href =
131 session.roomId && session.spaceName
132 ? '/app/' + encodeURIComponent(session.spaceName) + '/chat/' + encodeURIComponent(session.roomId)
133 : undefined
134
135 const connecting = session.status === 'connecting' || session.status === 'reconnecting'
136
137 return (
138 <div
139 ref={barRef}
140 className={[
141 'voice-call-bar',
142 position ? 'is-moved' : '',
143 dragging ? 'is-dragging' : '',
144 ]
145 .filter(Boolean)
146 .join(' ')}
147 style={position ? { left: position.x, top: position.y } : undefined}
148 role="region"
149 aria-label="通话"
150 title="按住空白处可拖动,双击复位"
151 onPointerDown={onPointerDown}
152 onPointerMove={onPointerMove}
153 onPointerUp={endDrag}
154 onPointerCancel={endDrag}
155 onDoubleClick={event => {
156 if (!isInteractive(event.target)) resetPosition()
157 }}
158 >
159 <GripVertical className="voice-call-grip" aria-hidden />
160 <span className="voice-call-status">
161 {connecting ? (
162 <Loader2 className="h-3.5 w-3.5 animate-spin" />
163 ) : (
164 <span className="voice-call-dot" aria-hidden />
165 )}
166 <span className="voice-call-label">{roomLabel}</span>
167 </span>
168
169 {!session.audioPlaybackAllowed ? (
170 <button
171 type="button"
172 className="voice-call-resume"
173 onClick={() => void voiceSession.resumeAudio()}
174 >
175 <AlertCircle className="h-3.5 w-3.5" />
176 恢复声音
177 </button>
178 ) : null}
179
180 {session.notice ? (
181 <span className="voice-call-notice truncate" title={session.notice}>
182 {session.notice}
183 </span>
184 ) : null}
185
186 <span className="voice-call-actions">
187 <button
188 type="button"
189 className={session.micEnabled ? 'is-on' : 'is-off'}
190 disabled={!session.canPublish}
191 title={session.canPublish ? '麦克风' : '当前没有发言权'}
192 onClick={() => void voiceSession.setMicrophone(!session.micEnabled)}
193 >
194 {session.micEnabled ? <Mic className="h-4 w-4" /> : <MicOff className="h-4 w-4" />}
195 </button>
196 <button
197 type="button"
198 className={session.cameraEnabled ? 'is-on' : 'is-off'}
199 disabled={!session.canPublish}
200 title="摄像头"
201 onClick={() => void voiceSession.setCamera(!session.cameraEnabled)}
202 >
203 {session.cameraEnabled ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
204 </button>
205 <button
206 type="button"
207 className={session.screenShareEnabled ? 'is-on' : 'is-off'}
208 disabled={!session.canPublish}
209 title="共享屏幕"
210 onClick={() => void voiceSession.setScreenShare(!session.screenShareEnabled)}
211 >
212 <MonitorUp className="h-4 w-4" />
213 </button>
214 {href ? (
215 <Link className="voice-call-return" to={href} title="回到语音频道">
216 <Volume2 className="h-4 w-4" />
217 </Link>
218 ) : null}
219 <button
220 type="button"
221 className="is-hangup"
222 title="离开通话"
223 onClick={() => void voiceSession.leave()}
224 >
225 <PhoneOff className="h-4 w-4" />
226 </button>
227 </span>
228 </div>
229 )
230}
Added web/src/features/chat/ui/voice/voice-danmaku-history.tsx View web/src/features/chat/ui/voice/voice-danmaku-history.tsx
@@ -0,0 +1,112 @@
1/**
2 * The bullet-comment archive: a small window over the call that lists what the
3 * room already said (newest first, paging backwards on demand) and carries the
4 * only switch that controls whether bullets fly across the screen at all.
5 */
6import { useEffect, useRef } from 'react'
7import { X } from 'lucide-react'
8import type { ChatMessage } from '@/entities/chat'
9import { fmtRelative } from '@/shared/lib/format'
10
11export interface VoiceDanmakuHistoryProps {
12 messages: ChatMessage[]
13 nameOf: (userId: string) => string
14 hasOlder: boolean
15 onLoadOlder: () => void
16 flying: boolean
17 onFlyingChange: (next: boolean) => void
18 onClose: () => void
19}
20
21function isBullet(message: ChatMessage): boolean {
22 if (message.deleted_at) return false
23 const type = message.message_type
24 return (type === 'Default' || type === 'Reply') && message.content.trim() !== ''
25}
26
27export function VoiceDanmakuHistory({
28 messages,
29 nameOf,
30 hasOlder,
31 onLoadOlder,
32 flying,
33 onFlyingChange,
34 onClose,
35}: VoiceDanmakuHistoryProps) {
36 const ref = useRef<HTMLDivElement | null>(null)
37
38 // Clicking anywhere else, or Escape, dismisses it. The toggle button is
39 // exempt: it handles its own close, and closing here first would make the
40 // following click re-open the window.
41 useEffect(() => {
42 const onPointerDown = (event: PointerEvent) => {
43 const target = event.target
44 if (!(target instanceof Element)) return
45 if (target.closest('[data-danmaku-toggle]')) return
46 if (ref.current?.contains(target)) return
47 onClose()
48 }
49 const onKeyDown = (event: KeyboardEvent) => {
50 if (event.key === 'Escape') onClose()
51 }
52 document.addEventListener('pointerdown', onPointerDown)
53 document.addEventListener('keydown', onKeyDown)
54 return () => {
55 document.removeEventListener('pointerdown', onPointerDown)
56 document.removeEventListener('keydown', onKeyDown)
57 }
58 }, [onClose])
59
60 const items = messages.filter(isBullet).slice().reverse()
61
62 return (
63 <div
64 ref={ref}
65 className="voice-danmaku-history"
66 role="dialog"
67 aria-label="弹幕记录"
68 >
69 <div className="voice-danmaku-history-head">
70 <span className="voice-danmaku-history-title">弹幕记录</span>
71 <span className="voice-badge">{items.length} 条</span>
72 <button
73 type="button"
74 className="voice-danmaku-history-close"
75 title="关闭"
76 onClick={onClose}
77 >
78 <X className="h-3.5 w-3.5" />
79 </button>
80 </div>
81 {hasOlder ? (
82 <button
83 type="button"
84 className="voice-danmaku-history-more"
85 onClick={onLoadOlder}
86 >
87 加载更早
88 </button>
89 ) : null}
90 <div className="voice-danmaku-history-list">
91 {items.length === 0 ? <p className="voice-hands-empty">还没有弹幕</p> : null}
92 {items.map(message => (
93 <div key={String(message.id)} className="voice-danmaku-history-row">
94 <span className="voice-danmaku-history-author">
95 {message.author?.display_name || nameOf(String(message.author_id))}
96 </span>
97 <span className="voice-danmaku-history-text">{message.content.trim()}</span>
98 <time className="voice-danmaku-history-time">{fmtRelative(message.created_at)}</time>
99 </div>
100 ))}
101 </div>
102 <label className="voice-danmaku-history-foot">
103 <input
104 type="checkbox"
105 checked={flying}
106 onChange={() => onFlyingChange(!flying)}
107 />
108 实时飘屏
109 </label>
110 </div>
111 )
112}
Added web/src/features/chat/ui/voice/voice-danmaku-input.tsx View web/src/features/chat/ui/voice/voice-danmaku-input.tsx
@@ -0,0 +1,68 @@
1/**
2 * The bullet-comment entry. Deliberately minimal: it parks as a slim, faded
3 * pill until it is focused, it is single-line, and it carries no attachments,
4 * no emoji picker and no keyboard hints — a voice channel is for talking, so the
5 * text box must never compete with the call for attention.
6 */
7import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
8import { MessageSquare, Send } from 'lucide-react'
9import { MAX_MESSAGE_LEN } from '@/entities/chat'
10
11export interface VoiceDanmakuInputProps {
12 value: string
13 onChange: (next: string) => void
14 onSend: () => void
15 disabled?: boolean
16 /** Only real failures surface: slow mode, a blocked draft. */
17 notice?: string | null
18}
19
20export function VoiceDanmakuInput({
21 value,
22 onChange,
23 onSend,
24 disabled = false,
25 notice = null,
26}: VoiceDanmakuInputProps) {
27 const [focused, setFocused] = useState(false)
28 const open = focused || value.length > 0
29 const sendable = value.trim().length > 0 && !disabled
30
31 const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
32 // Confirming a Chinese candidate with Enter must never fire a half-written
33 // bullet, so the composing flag is checked before anything else.
34 if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
35 event.preventDefault()
36 if (sendable) onSend()
37 }
38
39 return (
40 <div className={['voice-danmaku-input', open ? 'is-open' : ''].filter(Boolean).join(' ')}>
41 <MessageSquare className="voice-danmaku-input-icon" aria-hidden />
42 <input
43 className="voice-danmaku-input-field"
44 value={value}
45 disabled={disabled}
46 maxLength={MAX_MESSAGE_LEN}
47 placeholder="发条弹幕…"
48 aria-label="弹幕"
49 onChange={event => onChange(event.target.value)}
50 onFocus={() => setFocused(true)}
51 onBlur={() => setFocused(false)}
52 onKeyDown={onKeyDown}
53 />
54 {open && notice ? <span className="voice-danmaku-input-notice">{notice}</span> : null}
55 <button
56 type="button"
57 className="voice-danmaku-input-send"
58 disabled={!sendable}
59 title="发送"
60 onClick={() => {
61 if (sendable) onSend()
62 }}
63 >
64 <Send className="h-4 w-4" />
65 </button>
66 </div>
67 )
68}
Added web/src/features/chat/ui/voice/voice-danmaku.tsx View web/src/features/chat/ui/voice/voice-danmaku.tsx
@@ -0,0 +1,150 @@
1/**
2 * Bullet comments for voice channels: text posted to the room flies across the
3 * call surface instead of being read as a transcript.
4 *
5 * Two details make it bearable:
6 * - only traffic that arrives after the transcript has been seeded is replayed,
7 * so opening a channel never throws its whole history at you;
8 * - the layer is click-through and every bullet keeps a lane derived from its
9 * own id, so tiles stay usable and nothing jumps sideways when a neighbour
10 * expires.
11 */
12import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
13import type { ChatMessage } from '@/entities/chat'
14
15/** Seconds a bullet needs to cross the stage; keep in sync with the CSS. */
16const CROSS_MS = 11_000
17const LANE_HEIGHT = 40
18
19export interface VoiceDanmakuProps {
20 messages: ChatMessage[]
21 /** True once the first page loaded: everything up to it is history. */
22 seeded: boolean
23 nameOf: (userId: string) => string
24}
25
26interface Bullet {
27 id: string
28 author: string
29 text: string
30 lane: number
31}
32
33function isHumanText(message: ChatMessage): boolean {
34 if (message.deleted_at) return false
35 const type = message.message_type
36 return (type === 'Default' || type === 'Reply') && message.content.trim() !== ''
37}
38
39/** Server stamps carry a `+00:00:00` offset that Date cannot parse. */
40function stampOf(value: string): number {
41 const parsed = Date.parse(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2'))
42 return Number.isNaN(parsed) ? Number.NaN : parsed
43}
44
45/**
46 * Bullets are a live surface: one only flies when the message is genuinely new,
47 * so a late joiner never watches the room's past. Three guards, because the
48 * transcript can hand history over in several shapes (seed, resync, prefetch).
49 */
50function isLive(message: ChatMessage, born: number, threshold: number, now: number): boolean {
51 if (born === 0) return false
52 if ((message.seq ?? 0) <= threshold) return false
53 const stamped = stampOf(message.created_at)
54 if (!Number.isNaN(stamped) && now - stamped > CROSS_MS) return false
55 return true
56}
57
58
59function laneFor(id: string, lanes: number): number {
60 let hash = 0
61 for (let index = 0; index < id.length; index += 1) hash = (hash * 31 + id.charCodeAt(index)) % 9973
62 return hash % lanes
63}
64
65export function VoiceDanmaku({ messages, seeded, nameOf }: VoiceDanmakuProps) {
66 const layerRef = useRef<HTMLDivElement | null>(null)
67 const [lanes, setLanes] = useState(3)
68 const [distance, setDistance] = useState(0)
69 const historyUntil = useRef<number | null>(null)
70 const bornAt = useRef<Map<string, number>>(new Map())
71 const [, setTick] = useState(0)
72
73 useLayoutEffect(() => {
74 const el = layerRef.current
75 if (!el) return
76 const measure = () => {
77 setLanes(Math.max(1, Math.floor(el.clientHeight / LANE_HEIGHT)))
78 setDistance(el.clientWidth)
79 }
80 measure()
81 const observer = new ResizeObserver(measure)
82 observer.observe(el)
83 return () => observer.disconnect()
84 }, [])
85
86 // Freeze the seq the seed ended on: a larger seq can only be live traffic.
87 if (seeded && historyUntil.current === null) {
88 let newest = 0
89 for (const message of messages) newest = Math.max(newest, message.seq ?? 0)
90 historyUntil.current = newest
91 }
92 const threshold = historyUntil.current
93
94 const now = Date.now()
95 // Live traffic arrives one or two messages at a time; anything that lands as
96 // a page (seed refresh, resync, local-first prefetch) is history.
97 const unseen = messages.filter(
98 message => isHumanText(message) && !bornAt.current.has(String(message.id))
99 )
100 const pageIsHistory = unseen.length > 3
101 for (const message of unseen) {
102 bornAt.current.set(String(message.id), pageIsHistory ? 0 : now)
103 }
104
105 const bullets: Bullet[] = []
106 for (const message of messages) {
107 if (!isHumanText(message)) continue
108 const id = String(message.id)
109 const born = bornAt.current.get(id) ?? 0
110 if (!isLive(message, born, threshold ?? -1, now)) continue
111 if (now - born >= CROSS_MS) continue
112 bullets.push({
113 id,
114 author: message.author?.display_name || nameOf(String(message.author_id)),
115 text: message.content.trim(),
116 lane: laneFor(id, lanes),
117 })
118 }
119 if (bornAt.current.size > 300) {
120 for (const [id, born] of bornAt.current) if (born !== 0 && now - born >= CROSS_MS) bornAt.current.delete(id)
121 }
122
123 // Bullets expire on their own clock, not only when a new message renders.
124 useEffect(() => {
125 if (bullets.length === 0) return
126 const timer = setInterval(() => setTick(value => value + 1), 1000)
127 return () => clearInterval(timer)
128 // eslint-disable-next-line react-hooks/exhaustive-deps
129 }, [bullets.length])
130
131 return (
132 <div ref={layerRef} className="voice-danmaku" aria-hidden>
133 {bullets.map(bullet => (
134 <span
135 key={bullet.id}
136 className="voice-danmaku-item"
137 style={
138 {
139 top: bullet.lane * LANE_HEIGHT + 4,
140 '--voice-danmaku-distance': distance + 'px',
141 } as CSSProperties
142 }
143 >
144 <span className="voice-danmaku-author">{bullet.author}</span>
145 <span className="voice-danmaku-text">{bullet.text}</span>
146 </span>
147 ))}
148 </div>
149 )
150}
Added web/src/features/chat/ui/voice/voice-room-panel.tsx View web/src/features/chat/ui/voice/voice-room-panel.tsx
@@ -0,0 +1,749 @@
1/**
2 * The in-room voice surface: participant tiles (camera grid with the shared
3 * screen promoted to a main stage), the control bar, and — for Stage channels —
4 * the raise-hand/approve panel. It reads the session singleton but never owns
5 * the connection lifecycle, so leaving the page cannot hang up the call.
6 */
7import {
8 useCallback,
9 useEffect,
10 useMemo,
11 useRef,
12 useState,
13 type PointerEvent as ReactPointerEvent,
14} from 'react'
15import {
16 AlertCircle,
17 Hand,
18 Loader2,
19 LogOut,
20 Mic,
21 MicOff,
22 MonitorUp,
23 MessageSquare,
24 PhoneCall,
25 ShieldCheck,
26 Video,
27 VideoOff,
28} from 'lucide-react'
29import {
30 approveVoiceHand,
31 cancelVoiceHand,
32 denyVoiceHand,
33 getVoiceConfig,
34 listVoiceHands,
35 kickVoiceParticipant,
36 moderateVoiceMute,
37 raiseVoiceHand,
38 requestVoiceToken,
39 revokeVoiceHand,
40 type VoiceHands,
41 type VoiceParticipant,
42} from '@/entities/chat'
43import { useVoiceSession, voiceSession, participantTracks } from '@/shared/voice'
44import { useWsFrames } from '@/shared/ws'
45import { RoomEvent, type Track } from 'livekit-client'
46import type { ChatMessage } from '@/entities/chat'
47import { VoiceDanmaku } from './voice-danmaku'
48import { VoiceDanmakuHistory } from './voice-danmaku-history'
49
50export interface VoiceRoomPanelProps {
51 roomId: string
52 roomName: string
53 spaceName: string
54 isStage: boolean
55 /** Server-published participants (webhook backed). */
56 participants: VoiceParticipant[]
57 /** Room transcript, reused as the bullet-comment feed. */
58 danmakuMessages: ChatMessage[]
59 danmakuSeeded: boolean
60 danmakuHasOlder: boolean
61 onLoadOlderDanmaku: () => void
62 nameOf: (userId: string) => string
63 viewerId?: string
64 canModerate: boolean
65}
66
67function TrackVideo({ track, muted }: { track: Track; muted: boolean }) {
68 const ref = useRef<HTMLVideoElement>(null)
69 useEffect(() => {
70 const element = ref.current
71 if (!element) return
72 track.attach(element)
73 return () => {
74 track.detach(element)
75 }
76 }, [track])
77 return (
78 <video
79 ref={ref}
80 autoPlay
81 playsInline
82 muted={muted}
83 className="h-full w-full rounded-xl bg-black object-contain"
84 />
85 )
86}
87
88/** A participant window the user has parked somewhere on the canvas. */
89interface TilePlacement {
90 x: number
91 y: number
92 w: number
93}
94
95interface TileGesture {
96 mode: 'move' | 'resize'
97 key: string
98 origin: TilePlacement
99 startX: number
100 startY: number
101}
102
103const LAYOUT_PREFIX = 'gitdata.voice.tiles.'
104const TILE_MIN_WIDTH = 140
105const TILE_MAX_WIDTH = 640
106const TILE_SNAP = 4
107const TILE_RATIO = 3 / 4
108
109function readLayout(roomId: string): Record<string, TilePlacement> {
110 try {
111 const raw = localStorage.getItem(LAYOUT_PREFIX + roomId)
112 if (!raw) return {}
113 const parsed = JSON.parse(raw) as Record<string, Partial<TilePlacement>>
114 const out: Record<string, TilePlacement> = {}
115 for (const [key, value] of Object.entries(parsed)) {
116 if (
117 typeof value?.x === 'number' &&
118 typeof value.y === 'number' &&
119 typeof value.w === 'number'
120 ) {
121 out[key] = { x: value.x, y: value.y, w: value.w }
122 }
123 }
124 return out
125 } catch {
126 return {}
127 }
128}
129
130function writeLayout(roomId: string, layout: Record<string, TilePlacement>): void {
131 try {
132 const key = LAYOUT_PREFIX + roomId
133 if (Object.keys(layout).length === 0) localStorage.removeItem(key)
134 else localStorage.setItem(key, JSON.stringify(layout))
135 } catch {
136 // A layout that cannot persist is not worth failing the call over.
137 }
138}
139
140const DANMAKU_KEY = 'gitdata.voice.danmaku'
141
142function readDanmakuPreference(): boolean {
143 try {
144 return localStorage.getItem(DANMAKU_KEY) !== 'off'
145 } catch {
146 return true
147 }
148}
149
150function toggleDanmakuPreference(next: boolean): void {
151 try {
152 localStorage.setItem(DANMAKU_KEY, next ? 'on' : 'off')
153 } catch {
154 // A preference that cannot persist is not worth failing the call over.
155 }
156}
157
158function initials(name: string): string {
159 const trimmed = name.trim()
160 if (!trimmed) return '?'
161 return [...trimmed].slice(0, 2).join('').toUpperCase()
162}
163
164export function VoiceRoomPanel({
165 roomId,
166 roomName,
167 spaceName,
168 isStage,
169 participants,
170 danmakuMessages,
171 danmakuSeeded,
172 danmakuHasOlder,
173 onLoadOlderDanmaku,
174 nameOf,
175 viewerId,
176 canModerate,
177}: VoiceRoomPanelProps) {
178 const session = useVoiceSession()
179 const joined = session.roomId === roomId && session.status !== 'idle'
180 const [joining, setJoining] = useState(false)
181 const [error, setError] = useState<string | null>(null)
182 const [hands, setHands] = useState<VoiceHands | null>(null)
183 const [renderTick, setRenderTick] = useState(0)
184 const [danmakuOn, setDanmakuOn] = useState(readDanmakuPreference)
185 const [historyOpen, setHistoryOpen] = useState(false)
186 const canvasRef = useRef<HTMLDivElement | null>(null)
187 const canvasBox = useRef({ width: 0, height: 0 })
188 const gestureRef = useRef<TileGesture | null>(null)
189 const [layout, setLayout] = useState<Record<string, TilePlacement>>(() => readLayout(roomId))
190 const layoutRef = useRef(layout)
191
192 // The canvas box is what keeps parked windows inside it after a resize.
193 useEffect(() => {
194 const el = canvasRef.current
195 if (!el) return
196 const measure = () => {
197 canvasBox.current = { width: el.clientWidth, height: el.clientHeight }
198 }
199 measure()
200 const observer = new ResizeObserver(measure)
201 observer.observe(el)
202 return () => observer.disconnect()
203 }, [])
204
205 const commitLayout = (next: Record<string, TilePlacement>) => {
206 layoutRef.current = next
207 setLayout(next)
208 }
209
210 const clampPlacement = (placement: TilePlacement): TilePlacement => {
211 const w = Math.round(Math.min(Math.max(TILE_MIN_WIDTH, placement.w), TILE_MAX_WIDTH))
212 const { width, height } = canvasBox.current
213 if (width === 0 || height === 0) {
214 return { x: Math.round(placement.x), y: Math.round(placement.y), w }
215 }
216 return {
217 w,
218 x: Math.round(Math.min(Math.max(0, placement.x), Math.max(0, width - w))),
219 y: Math.round(
220 Math.min(Math.max(0, placement.y), Math.max(0, height - w * TILE_RATIO))
221 ),
222 }
223 }
224
225 const snap = (value: number) => Math.round(value / TILE_SNAP) * TILE_SNAP
226
227 const beginTileGesture = (
228 event: ReactPointerEvent<HTMLElement>,
229 key: string,
230 mode: 'move' | 'resize'
231 ) => {
232 const canvas = canvasRef.current
233 if (!canvas || event.button !== 0) return
234 // The moderation buttons stay clickable: grabbing a button is not grabbing
235 // the window, and pointer capture on the tile would swallow the click.
236 if (event.target instanceof Element && event.target.closest('button')) return
237 const el = canvas.querySelector<HTMLElement>('[data-tile="' + key + '"]')
238 if (!el) return
239 const canvasRect = canvas.getBoundingClientRect()
240 const tileRect = el.getBoundingClientRect()
241 const origin =
242 layoutRef.current[key] ??
243 clampPlacement({
244 x: tileRect.left - canvasRect.left + canvas.scrollLeft,
245 y: tileRect.top - canvasRect.top + canvas.scrollTop,
246 w: tileRect.width,
247 })
248 gestureRef.current = { mode, key, origin, startX: event.clientX, startY: event.clientY }
249 // A grid window lifts out of the flow at exactly where it was drawn, so
250 // the first pointer-down never makes it jump.
251 if (!layoutRef.current[key]) commitLayout({ ...layoutRef.current, [key]: origin })
252 el.setPointerCapture(event.pointerId)
253 event.preventDefault()
254 }
255
256 const moveTileGesture = (event: ReactPointerEvent<HTMLElement>, key: string) => {
257 const active = gestureRef.current
258 if (!active || active.key !== key) return
259 const dx = event.clientX - active.startX
260 const dy = event.clientY - active.startY
261 const next =
262 active.mode === 'move'
263 ? { x: snap(active.origin.x + dx), y: snap(active.origin.y + dy), w: active.origin.w }
264 : { ...active.origin, w: snap(active.origin.w + dx) }
265 commitLayout({ ...layoutRef.current, [key]: clampPlacement(next) })
266 }
267
268 const endTileGesture = (key: string) => {
269 const active = gestureRef.current
270 if (!active || active.key !== key) return
271 gestureRef.current = null
272 writeLayout(roomId, layoutRef.current)
273 }
274
275 const releaseTile = (key: string) => {
276 const next = { ...layoutRef.current }
277 delete next[key]
278 commitLayout(next)
279 writeLayout(roomId, next)
280 }
281 const room = voiceSession.getRoom()
282 const trackByIdentity = useMemo(() => {
283 const map = new Map<string, Track>()
284 if (!room) return map
285 for (const participant of room.remoteParticipants.values()) {
286 const { camera, screen } = participantTracks(participant)
287 const track = screen ?? camera
288 if (track) map.set(participant.identity, track)
289 }
290 // The local camera is published into our own publications; without this
291 // the self tile would stay on the avatar while the camera is live for
292 // everyone else.
293 const local = participantTracks(room.localParticipant)
294 const localTrack = local.screen ?? local.camera
295 if (localTrack) map.set(room.localParticipant.identity, localTrack)
296 return map
297 // renderTick is the redraw signal for track attach/detach.
298 // eslint-disable-next-line react-hooks/exhaustive-deps
299 }, [room, renderTick])
300
301 const screenSharer = useMemo(() => {
302 if (!room) return null
303 for (const participant of room.remoteParticipants.values()) {
304 const { screen } = participantTracks(participant)
305 if (screen) return { identity: participant.identity, track: screen }
306 }
307 const { screen } = participantTracks(room.localParticipant)
308 if (screen) return { identity: room.localParticipant.identity, track: screen }
309 return null
310 // eslint-disable-next-line react-hooks/exhaustive-deps
311 }, [room, renderTick])
312
313 // The LiveKit room emits at its own cadence; fold it into one redraw counter
314 // so the tile memo above re-derives without subscribing to every event.
315 useEffect(() => {
316 if (!room) return
317 const bump = () => setRenderTick(value => value + 1)
318 room
319 .on(RoomEvent.LocalTrackPublished, bump)
320 .on(RoomEvent.LocalTrackUnpublished, bump)
321 .on(RoomEvent.TrackSubscribed, bump)
322 .on(RoomEvent.TrackUnsubscribed, bump)
323 .on(RoomEvent.ParticipantConnected, bump)
324 .on(RoomEvent.ParticipantDisconnected, bump)
325 .on(RoomEvent.ActiveSpeakersChanged, bump)
326 return () => {
327 room
328 .off(RoomEvent.LocalTrackPublished, bump)
329 .off(RoomEvent.LocalTrackUnpublished, bump)
330 .off(RoomEvent.TrackSubscribed, bump)
331 .off(RoomEvent.TrackUnsubscribed, bump)
332 .off(RoomEvent.ParticipantConnected, bump)
333 .off(RoomEvent.ParticipantDisconnected, bump)
334 .off(RoomEvent.ActiveSpeakersChanged, bump)
335 }
336 }, [room])
337
338 const refreshHands = useCallback(async () => {
339 if (!isStage) return
340 try {
341 setHands(await listVoiceHands(roomId))
342 } catch {
343 // A failed hand list must not break the call surface.
344 }
345 }, [isStage, roomId])
346
347 // The queue now decides whether the panel exists, so a raised hand has to
348 // open it immediately instead of on the next poll.
349 useWsFrames(frame => {
350 if (!('room_id' in frame) || frame.room_id !== roomId) return
351 if (frame.type === 'event' && frame.event?.kind === 'voice_hands') void refreshHands()
352 })
353
354 useEffect(() => {
355 void refreshHands()
356 }, [refreshHands, session.status])
357
358 useEffect(() => {
359 if (!isStage || !joined) return
360 const timer = setInterval(() => void refreshHands(), 15_000)
361 return () => clearInterval(timer)
362 }, [isStage, joined, refreshHands])
363
364 const join = useCallback(async () => {
365 setJoining(true)
366 setError(null)
367 try {
368 const config = await getVoiceConfig()
369 if (!config.enabled) throw new Error('语音功能未启用')
370 const token = await requestVoiceToken(roomId)
371 await voiceSession.join({
372 roomId,
373 roomLabel: roomName,
374 spaceName,
375 url: token.url,
376 token: token.token,
377 canPublish: token.can_publish,
378 isStage: token.is_stage,
379 startWithMic: token.can_publish && !token.is_stage,
380 })
381 voiceSession.markHandRaised(token.hand_state === 'pending' || token.hand_state === 'approved')
382 await voiceSession.resumeAudio()
383 await refreshHands()
384 } catch (cause) {
385 setError(cause instanceof Error ? cause.message : '加入语音失败')
386 } finally {
387 setJoining(false)
388 }
389 }, [roomId, roomName, spaceName, refreshHands])
390
391 const onRaiseHand = useCallback(async () => {
392 setError(null)
393 try {
394 await raiseVoiceHand(roomId)
395 voiceSession.markHandRaised(true)
396 await refreshHands()
397 } catch (cause) {
398 setError(cause instanceof Error ? cause.message : '举手失败')
399 }
400 }, [roomId, refreshHands])
401
402 const onCancelHand = useCallback(async () => {
403 setError(null)
404 try {
405 await cancelVoiceHand(roomId)
406 voiceSession.markHandRaised(false)
407 await refreshHands()
408 } catch (cause) {
409 setError(cause instanceof Error ? cause.message : '撤回失败')
410 }
411 }, [roomId, refreshHands])
412
413 const decide = useCallback(
414 async (userId: string, action: 'approve' | 'deny' | 'revoke') => {
415 setError(null)
416 try {
417 if (action === 'approve') await approveVoiceHand(roomId, userId)
418 else if (action === 'deny') await denyVoiceHand(roomId, userId)
419 else await revokeVoiceHand(roomId, userId)
420 await refreshHands()
421 } catch (cause) {
422 setError(cause instanceof Error ? cause.message : '审批失败')
423 }
424 },
425 [roomId, refreshHands],
426 )
427
428 const tiles = useMemo(
429 () =>
430 participants.map(participant => ({
431 ...participant,
432 isSelf: participant.user_id === viewerId,
433 videoTrack: trackByIdentity.get(participant.user_id),
434 })),
435 [participants, trackByIdentity, viewerId],
436 )
437
438 const myHand = hands?.my_state
439 const pendingHands = (hands?.hands ?? []).filter(hand => hand.state === 'pending')
440 const approvedHands = (hands?.hands ?? []).filter(hand => hand.state === 'approved')
441 // An empty moderation queue is not a panel worth reserving space for; the
442 // audience keeps theirs because it holds the raise-hand action itself.
443 const showHands =
444 isStage && (!canModerate || pendingHands.length > 0 || approvedHands.length > 0)
445
446 return (
447 <div className="voice-room flex min-h-0 flex-1 flex-col">
448 {session.notice || error ? (
449 <div className="voice-banners">
450 {session.notice ? (
451 <div className="voice-banner" role="status">
452 <AlertCircle className="h-3.5 w-3.5 shrink-0" />
453 <span className="min-w-0 truncate">{session.notice}</span>
454 <button type="button" onClick={() => voiceSession.clearNotice()}>
455 忽略
456 </button>
457 </div>
458 ) : null}
459 {error ? (
460 <div className="voice-banner" role="alert">
461 <AlertCircle className="h-3.5 w-3.5 shrink-0" />
462 <span className="min-w-0 truncate">{error}</span>
463 <button type="button" onClick={() => setError(null)}>
464 忽略
465 </button>
466 </div>
467 ) : null}
468 </div>
469 ) : null}
470 <div className="voice-call-body">
471 {danmakuOn ? (
472 <VoiceDanmaku messages={danmakuMessages} seeded={danmakuSeeded} nameOf={nameOf} />
473 ) : null}
474
475 {screenSharer ? (
476 <div className="voice-stage">
477 <TrackVideo track={screenSharer.track} muted={screenSharer.identity === viewerId} />
478 <div className="voice-stage-caption">
479 <MonitorUp className="h-3.5 w-3.5" />
480 {voiceSession.nameOf(screenSharer.identity)} 正在共享屏幕
481 </div>
482 </div>
483 ) : null}
484
485 {!joined ? (
486 <div className="voice-join">
487 <div className="voice-join-icon">
488 <PhoneCall className="h-6 w-6" />
489 </div>
490 <p className="voice-join-title">{roomName}</p>
491 <p className="voice-join-hint">
492 {isStage
493 ? '这是舞台频道:加入后可收听;想要发言请举手,主持人通过后自动开麦。'
494 : '加入语音后会开启麦克风,可随时静音或关闭摄像头。'}
495 </p>
496 {isStage && myHand === 'pending' ? (
497 <div className="flex flex-col items-center gap-2">
498 <span className="voice-hand-pending">
499 <Hand className="h-3.5 w-3.5" /> 已举手,等待主持人通过
500 </span>
501 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
502 撤回举手
503 </button>
504 </div>
505 ) : null}
506 <button
507 type="button"
508 className="voice-primary-button"
509 disabled={joining || session.status === 'connecting'}
510 onClick={() => void join()}
511 >
512 {joining || session.status === 'connecting' ? (
513 <>
514 <Loader2 className="h-4 w-4 animate-spin" /> 正在加入…
515 </>
516 ) : (
517 '加入语音'
518 )}
519 </button>
520 </div>
521 ) : (
522 <>
523 <div ref={canvasRef} className="voice-tiles" role="list">
524 {tiles.map(tile => {
525 const speaking = (tile.isSelf && session.speaking) || tile.speaking
526 const parked = layout[tile.user_id]
527 const placed = parked ? clampPlacement(parked) : null
528 return (
529 <div
530 key={tile.user_id}
531 data-tile={tile.user_id}
532 role="listitem"
533 className={[
534 'voice-tile',
535 speaking ? 'is-speaking' : '',
536 placed ? 'is-floating' : '',
537 ]
538 .filter(Boolean)
539 .join(' ')}
540 style={placed ? { left: placed.x, top: placed.y, width: placed.w } : undefined}
541 title={placed ? '拖动可摆位,右下角缩放,双击放回网格' : '按住可拖到任意空位'}
542 onPointerDown={event => beginTileGesture(event, tile.user_id, 'move')}
543 onPointerMove={event => moveTileGesture(event, tile.user_id)}
544 onPointerUp={() => endTileGesture(tile.user_id)}
545 onPointerCancel={() => endTileGesture(tile.user_id)}
546 onDoubleClick={event => {
547 if (event.target instanceof Element && event.target.closest('button')) return
548 releaseTile(tile.user_id)
549 }}
550 >
551 {tile.videoTrack ? (
552 <TrackVideo track={tile.videoTrack} muted={tile.isSelf} />
553 ) : (
554 <div className="voice-tile-avatar">
555 {tile.avatar_url ? (
556 <img src={tile.avatar_url} alt="" />
557 ) : (
558 <span>{initials(tile.display_name)}</span>
559 )}
560 </div>
561 )}
562 <div className="voice-tile-meta">
563 <span className="truncate">{tile.display_name}</span>
564 {tile.moderator_muted || !tile.can_publish ? (
565 <MicOff className="voice-tile-flag h-3 w-3 shrink-0" />
566 ) : null}
567 {canModerate && !tile.isSelf ? (
568 <div className="voice-tile-actions">
569 <button
570 type="button"
571 title={tile.moderator_muted ? '解除静音' : '强制静音'}
572 onClick={() =>
573 void moderateVoiceMute(roomId, tile.user_id, !tile.moderator_muted)
574 }
575 >
576 {tile.moderator_muted ? (
577 <Mic className="h-3.5 w-3.5" />
578 ) : (
579 <MicOff className="h-3.5 w-3.5" />
580 )}
581 </button>
582 <button
583 type="button"
584 title="移出通话"
585 onClick={() => void kickVoiceParticipant(roomId, tile.user_id)}
586 >
587 <LogOut className="h-3.5 w-3.5" />
588 </button>
589 </div>
590 ) : null}
591 </div>
592 <span
593 className="voice-tile-resize"
594 aria-hidden
595 onPointerDown={event => {
596 event.stopPropagation()
597 beginTileGesture(event, tile.user_id, 'resize')
598 }}
599 />
600 </div>
601 )
602 })}
603 {tiles.length === 0 ? (
604 <p className="voice-empty">还没有其他人和你一起,邀请成员加入吧。</p>
605 ) : null}
606 </div>
607
608 {showHands ? (
609 <div className="voice-hands">
610 <div className="voice-hands-head">
611 <Hand className="h-4 w-4" />
612 <span>发言申请</span>
613 {!canModerate ? (
614 myHand === 'approved' ? (
615 <span className="voice-badge ok">
616 <ShieldCheck className="h-3 w-3" /> 已获得发言权
617 </span>
618 ) : session.hasRaisedHand || myHand === 'pending' ? (
619 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
620 撤回举手
621 </button>
622 ) : (
623 <button type="button" className="voice-ghost-button" onClick={onRaiseHand}>
624 举手申请发言
625 </button>
626 )
627 ) : (
628 <span className="voice-badge">{pendingHands.length} 待处理</span>
629 )}
630 </div>
631 {canModerate ? (
632 pendingHands.length === 0 ? (
633 <p className="voice-hands-empty">暂无待处理申请</p>
634 ) : (
635 <ul className="voice-hands-list">
636 {pendingHands.map(hand => (
637 <li key={hand.user_id}>
638 <span className="truncate">{hand.display_name}</span>
639 <span className="voice-hands-actions">
640 <button
641 type="button"
642 className="voice-approve"
643 onClick={() => void decide(hand.user_id, 'approve')}
644 >
645 通过
646 </button>
647 <button
648 type="button"
649 className="voice-deny"
650 onClick={() => void decide(hand.user_id, 'deny')}
651 >
652 拒绝
653 </button>
654 </span>
655 </li>
656 ))}
657 </ul>
658 )
659 ) : null}
660 {canModerate && approvedHands.length > 0 ? (
661 <ul className="voice-hands-list">
662 {approvedHands.map(hand => (
663 <li key={hand.user_id}>
664 <span className="truncate">{hand.display_name} · 发言中</span>
665 <span className="voice-hands-actions">
666 <button
667 type="button"
668 className="voice-deny"
669 onClick={() => void decide(hand.user_id, 'revoke')}
670 >
671 收回
672 </button>
673 </span>
674 </li>
675 ))}
676 </ul>
677 ) : null}
678 </div>
679 ) : null}
680
681 {historyOpen ? (
682 <VoiceDanmakuHistory
683 messages={danmakuMessages}
684 nameOf={nameOf}
685 hasOlder={danmakuHasOlder}
686 onLoadOlder={onLoadOlderDanmaku}
687 flying={danmakuOn}
688 onFlyingChange={next => {
689 setDanmakuOn(next)
690 toggleDanmakuPreference(next)
691 }}
692 onClose={() => setHistoryOpen(false)}
693 />
694 ) : null}
695
696 <div className="voice-controls">
697 <button
698 type="button"
699 className={['voice-control', session.micEnabled ? '' : 'is-off'].join(' ')}
700 disabled={!session.canPublish}
701 title={session.canPublish ? '麦克风' : '当前没有发言权'}
702 onClick={() => void voiceSession.setMicrophone(!session.micEnabled)}
703 >
704 {session.micEnabled ? <Mic className="h-4 w-4" /> : <MicOff className="h-4 w-4" />}
705 </button>
706 <button
707 type="button"
708 className={['voice-control', session.cameraEnabled ? '' : 'is-off'].join(' ')}
709 disabled={!session.canPublish}
710 title="摄像头"
711 onClick={() => void voiceSession.setCamera(!session.cameraEnabled)}
712 >
713 {session.cameraEnabled ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
714 </button>
715 <button
716 type="button"
717 className={['voice-control', session.screenShareEnabled ? '' : 'is-off'].join(' ')}
718 disabled={!session.canPublish}
719 title="共享屏幕"
720 onClick={() => void voiceSession.setScreenShare(!session.screenShareEnabled)}
721 >
722 <MonitorUp className="h-4 w-4" />
723 </button>
724 <button
725 type="button"
726 data-danmaku-toggle="true"
727 className={['voice-control', historyOpen ? 'is-active' : ''].join(' ')}
728 title="弹幕记录"
729 aria-expanded={historyOpen}
730 onClick={() => setHistoryOpen(value => !value)}
731 >
732 <MessageSquare className="h-4 w-4" />
733 </button>
734 <span className="voice-control-sep" aria-hidden />
735 <button
736 type="button"
737 className="voice-control danger"
738 title="离开通话"
739 onClick={() => void voiceSession.leave()}
740 >
741 <LogOut className="h-4 w-4" />
742 </button>
743 </div>
744 </>
745 )}
746 </div>
747 </div>
748 )
749}
Added web/src/features/chat/ui/voice/voice.css View web/src/features/chat/ui/voice/voice.css
@@ -0,0 +1,569 @@
1/* 语音频道:通话面板与全局通话条。只用 shell 设计令牌,深浅色自适应。 */
2
3.voice-room { background: var(--shell-surface); }
4/* The call surface the bullets fly across: everything but the banners. */
5.voice-call-body {
6 position: relative;
7 display: flex;
8 flex: 1;
9 min-height: 0;
10 flex-direction: column;
11}
12
13/* 提示条 */
14.voice-banners { display: flex; flex-shrink: 0; flex-wrap: wrap; gap: 8px; padding: 12px 16px 0; }
15.voice-banner {
16 display: flex;
17 align-items: center;
18 gap: 8px;
19 padding: 5px 6px 5px 11px;
20 border: 1px solid color-mix(in srgb, var(--destructive) 22%, transparent);
21 border-radius: 999px;
22 background: color-mix(in srgb, var(--destructive) 7%, var(--shell-surface));
23 color: var(--error-foreground);
24 font-size: 12px;
25}
26.voice-banner button {
27 flex-shrink: 0;
28 padding: 2px 9px;
29 border-radius: 999px;
30 color: inherit;
31 font-size: 11.5px;
32 opacity: .7;
33 transition: opacity 150ms, background 150ms;
34}
35.voice-banner button:hover { opacity: 1; background: color-mix(in srgb, var(--destructive) 12%, transparent); }
36
37/* 屏幕共享主画面 */
38.voice-stage {
39 position: relative;
40 flex-shrink: 0;
41 height: min(46vh, 420px);
42 margin: 14px 16px 0;
43 border: 1px solid var(--shell-hairline);
44 border-radius: 16px;
45 overflow: hidden;
46 background: #101213;
47 box-shadow: var(--shell-shadow);
48}
49.voice-stage-caption {
50 position: absolute;
51 left: 12px;
52 bottom: 12px;
53 display: flex;
54 align-items: center;
55 gap: 6px;
56 padding: 5px 11px;
57 border-radius: 999px;
58 background: rgb(17 17 17 / 62%);
59 backdrop-filter: blur(6px);
60 color: #fff;
61 font-size: 11.5px;
62}
63
64/* 未加入时的引导 */
65.voice-join {
66 display: flex;
67 flex: 1;
68 min-height: 0;
69 flex-direction: column;
70 align-items: center;
71 justify-content: center;
72 gap: 12px;
73 padding: 28px;
74 text-align: center;
75}
76.voice-join-icon {
77 display: grid;
78 width: 56px;
79 height: 56px;
80 place-items: center;
81 border-radius: 18px;
82 color: var(--shell-ink-muted);
83 background: var(--shell-field);
84 box-shadow: var(--shell-shadow);
85}
86.voice-join-title { font-size: 15px; font-weight: 600; }
87.voice-join-hint { max-width: 400px; color: var(--shell-ink-muted); font-size: 12.5px; line-height: 1.7; }
88.voice-primary-button {
89 display: inline-flex;
90 align-items: center;
91 gap: 6px;
92 margin-top: 6px;
93 padding: 10px 22px;
94 border-radius: 12px;
95 background: var(--shell-action);
96 color: #fff;
97 font-size: 13px;
98 font-weight: 600;
99 box-shadow: var(--shell-shadow);
100 transition: filter 160ms, transform 160ms;
101}
102.voice-primary-button:hover:not(:disabled) { filter: brightness(1.08); transform: translateY(-1px); }
103.voice-primary-button:active:not(:disabled) { transform: none; }
104.voice-primary-button:disabled { opacity: .55; }
105.voice-ghost-button {
106 padding: 6px 13px;
107 border: 1px solid var(--shell-hairline);
108 border-radius: 999px;
109 color: var(--shell-ink);
110 font-size: 12px;
111 transition: background 160ms, border-color 160ms;
112}
113.voice-ghost-button:hover { background: var(--shell-active); border-color: var(--shell-focus); }
114.voice-hand-pending {
115 display: inline-flex;
116 align-items: center;
117 gap: 5px;
118 padding: 5px 12px;
119 border-radius: 999px;
120 background: color-mix(in srgb, var(--warning-foreground) 14%, transparent);
121 color: var(--warning-foreground);
122 font-size: 12px;
123}
124
125/* 参与者 */
126.voice-tiles {
127 display: grid;
128 flex: 1;
129 min-height: 0;
130 grid-template-columns: repeat(auto-fit, minmax(178px, 228px));
131 justify-content: center;
132 align-content: safe center;
133 gap: 14px;
134 padding: 18px 16px;
135 overflow-y: auto;
136}
137.voice-tile {
138 position: relative;
139 display: flex;
140 aspect-ratio: 4 / 3;
141 flex-direction: column;
142 overflow: hidden;
143 border: 1px solid var(--shell-hairline);
144 border-radius: 16px;
145 background: var(--shell-field);
146 box-shadow: var(--shell-shadow);
147 transition: border-color 160ms, box-shadow 160ms;
148}
149.voice-tile.is-speaking {
150 border-color: color-mix(in srgb, var(--primary) 70%, transparent);
151 box-shadow:
152 0 0 0 2px color-mix(in srgb, var(--primary) 26%, transparent),
153 var(--shell-shadow);
154}
155.voice-tile-avatar { display: grid; flex: 1; place-items: center; font-size: 15px; font-weight: 600; }
156.voice-tile-avatar span {
157 display: grid;
158 width: 54px;
159 height: 54px;
160 place-items: center;
161 border-radius: 50%;
162 background: var(--shell-tile);
163 color: var(--shell-ink);
164}
165.voice-tile-avatar img { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; }
166.voice-tile-meta {
167 position: absolute;
168 inset: auto 0 0 0;
169 display: flex;
170 align-items: center;
171 gap: 6px;
172 padding: 16px 9px 8px;
173 background: linear-gradient(to top, rgb(10 12 13 / 66%), transparent);
174 color: #fff;
175 font-size: 12px;
176 font-weight: 500;
177}
178.voice-tile-flag { flex: none; color: #ff9c86; }
179.voice-tile-actions { display: flex; margin-left: auto; gap: 2px; opacity: 0; transition: opacity 160ms; }
180.voice-tile:hover .voice-tile-actions,
181.voice-tile:focus-within .voice-tile-actions { opacity: 1; }
182.voice-tile-actions button {
183 display: grid;
184 width: 24px;
185 height: 24px;
186 place-items: center;
187 border-radius: 8px;
188 color: #fff;
189 background: rgb(255 255 255 / 16%);
190}
191.voice-tile-actions button:hover { background: rgb(255 255 255 / 32%); }
192.voice-empty {
193 grid-column: 1 / -1;
194 padding: 40px 8px;
195 color: var(--shell-ink-muted);
196 font-size: 12.5px;
197 text-align: center;
198}
199
200/* 参与者可摆位:拖动/缩放后离开网格,成为可复位的浮窗 */
201.voice-tiles { position: relative; }
202.voice-tile { user-select: none; touch-action: none; cursor: grab; }
203.voice-tile:active { cursor: grabbing; }
204.voice-tile-actions button { cursor: pointer; }
205.voice-tile.is-floating { position: absolute; box-shadow: 0 12px 30px rgb(15 17 19 / 18%); }
206.voice-tile.is-speaking, .voice-tile.is-floating.is-speaking {
207 box-shadow:
208 0 0 0 2px color-mix(in srgb, var(--primary) 26%, transparent),
209 0 12px 30px rgb(15 17 19 / 18%);
210}
211.voice-tile-resize {
212 position: absolute;
213 right: 2px;
214 bottom: 2px;
215 z-index: 1;
216 width: 18px;
217 height: 18px;
218 border-radius: 6px;
219 cursor: nwse-resize;
220 opacity: 0;
221 background: linear-gradient(
222 135deg,
223 transparent 40%,
224 rgb(255 255 255 / 78%) 40%,
225 rgb(255 255 255 / 78%) 55%,
226 transparent 55%,
227 transparent 68%,
228 rgb(255 255 255 / 78%) 68%,
229 rgb(255 255 255 / 78%) 83%,
230 transparent 83%
231 );
232}
233.voice-tile:hover .voice-tile-resize { opacity: .85; }
234
235/* 舞台发言申请 */
236.voice-hands {
237 flex-shrink: 0;
238 margin: 0 16px 12px;
239 padding: 12px 14px;
240 border: 1px solid var(--shell-hairline);
241 border-radius: 14px;
242 background: var(--shell-field);
243}
244.voice-hands-head { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
245.voice-badge {
246 margin-left: auto;
247 padding: 2px 9px;
248 border-radius: 999px;
249 background: var(--shell-active);
250 color: var(--shell-ink-muted);
251 font-size: 11px;
252 font-weight: 500;
253}
254.voice-badge.ok {
255 display: inline-flex;
256 align-items: center;
257 gap: 4px;
258 background: color-mix(in srgb, var(--success-foreground) 12%, transparent);
259 color: var(--success-foreground);
260}
261.voice-hands-empty { margin-top: 8px; color: var(--shell-ink-muted); font-size: 12px; }
262.voice-hands-list { margin-top: 8px; display: flex; flex-direction: column; gap: 2px; }
263.voice-hands-list li {
264 display: flex;
265 align-items: center;
266 gap: 8px;
267 padding: 4px 6px;
268 border-radius: 8px;
269 font-size: 12.5px;
270}
271.voice-hands-list li:hover { background: var(--shell-active); }
272.voice-hands-actions { display: flex; margin-left: auto; gap: 6px; }
273.voice-approve,
274.voice-deny { padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 500; transition: filter 150ms; }
275.voice-approve { background: color-mix(in srgb, var(--success-foreground) 14%, transparent); color: var(--success-foreground); }
276.voice-deny { background: color-mix(in srgb, var(--destructive) 14%, transparent); color: var(--error-foreground); }
277.voice-approve:hover,
278.voice-deny:hover { filter: brightness(.95); }
279
280/* 控制条:居中的悬浮胶囊 */
281.voice-controls {
282 display: flex;
283 flex-shrink: 0;
284 align-self: center;
285 align-items: center;
286 gap: 4px;
287 margin: 0 auto 16px;
288 padding: 6px;
289 border: 1px solid var(--shell-hairline);
290 border-radius: 999px;
291 background: var(--shell-surface);
292 box-shadow: var(--shell-shadow);
293}
294.voice-control {
295 display: grid;
296 width: 38px;
297 height: 38px;
298 place-items: center;
299 border-radius: 50%;
300 color: var(--shell-ink);
301 transition: background 150ms, color 150ms;
302}
303.voice-control:hover:not(:disabled) { background: var(--shell-active); }
304.voice-control:disabled { opacity: .4; }
305.voice-control.is-off {
306 background: color-mix(in srgb, var(--destructive) 12%, transparent);
307 color: var(--error-foreground);
308}
309.voice-control-sep { flex: none; width: 1px; height: 20px; margin: 0 4px; background: var(--shell-hairline); }
310.voice-control.danger {
311 margin-left: 2px;
312 background: color-mix(in srgb, var(--destructive) 14%, transparent);
313 color: var(--destructive);
314}
315.voice-control.danger:hover { background: color-mix(in srgb, var(--destructive) 24%, transparent); }
316.voice-primary-button:focus-visible,
317.voice-ghost-button:focus-visible,
318.voice-control:focus-visible,
319.voice-call-actions > button:focus-visible,
320.voice-call-return:focus-visible {
321 outline: 2px solid var(--shell-focus);
322 outline-offset: 2px;
323}
324
325/* 弹幕:文字从通话画面上方飘过,点击穿透,不影响操作 */
326.voice-danmaku {
327 position: absolute;
328 inset: 10px 0 auto;
329 z-index: 3;
330 height: min(42%, 260px);
331 overflow: hidden;
332 pointer-events: none;
333}
334.voice-danmaku-item {
335 position: absolute;
336 left: 100%;
337 display: inline-flex;
338 align-items: baseline;
339 gap: 7px;
340 max-width: min(60vw, 520px);
341 padding: 4px 12px;
342 border-radius: 999px;
343 background: rgb(17 19 20 / 62%);
344 backdrop-filter: blur(4px);
345 color: #fff;
346 font-size: 12.5px;
347 line-height: 18px;
348 white-space: nowrap;
349 overflow: hidden;
350 animation-name: voice-danmaku-cross;
351 animation-duration: 11s;
352 animation-timing-function: linear;
353 animation-fill-mode: forwards;
354}
355.voice-danmaku-author {
356 flex: none;
357 color: color-mix(in srgb, var(--primary) 72%, #fff);
358 font-weight: 600;
359}
360.voice-danmaku-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
361@keyframes voice-danmaku-cross {
362 from { transform: translateX(0); }
363 to { transform: translateX(calc(-100% - var(--voice-danmaku-distance, 100vw))); }
364}
365
366/* 弹幕输入:默认收起成一条细胶囊,无附件、无表情、无键盘提示 */
367.voice-danmaku-input {
368 display: flex;
369 flex-shrink: 0;
370 align-items: center;
371 gap: 8px;
372 /* Fixed width: focusing must never make the bar jump or spill over. */
373 width: min(380px, calc(100% - 32px));
374 margin: 0 auto 14px;
375 padding: 0 5px 0 12px;
376 border: 1px solid var(--shell-hairline);
377 border-radius: 999px;
378 background: var(--shell-surface);
379 box-shadow: var(--shell-shadow);
380 opacity: .7;
381 transition: opacity 160ms, border-color 160ms;
382}
383.voice-danmaku-input:hover,
384.voice-danmaku-input.is-open { opacity: 1; }
385.voice-danmaku-input.is-open { border-color: var(--shell-focus); }
386.voice-danmaku-input-icon { flex: none; width: 15px; height: 15px; color: var(--shell-ink-muted); }
387.voice-danmaku-input-field {
388 min-width: 0;
389 flex: 1;
390 height: 34px;
391 border: 0;
392 background: transparent;
393 color: var(--shell-ink);
394 font-size: 13px;
395 outline: none;
396}
397.voice-danmaku-input-field:disabled { opacity: .5; }
398.voice-danmaku-input-notice { flex: none; color: var(--error-foreground); font-size: 11.5px; }
399.voice-danmaku-input-send {
400 display: grid;
401 flex: none;
402 width: 30px;
403 height: 30px;
404 place-items: center;
405 border-radius: 50%;
406 color: var(--shell-ink);
407 transition: background 150ms;
408}
409.voice-danmaku-input-send:hover:not(:disabled) { background: var(--shell-active); }
410.voice-danmaku-input-send:disabled { opacity: .35; }
411/* 弹幕记录小窗 */
412.voice-danmaku-history {
413 position: absolute;
414 bottom: 62px;
415 left: 50%;
416 z-index: 4;
417 display: flex;
418 flex-direction: column;
419 width: min(360px, calc(100% - 24px));
420 max-height: min(56%, 420px);
421 transform: translateX(-50%);
422 border: 1px solid var(--shell-hairline);
423 border-radius: 14px;
424 background: var(--shell-surface);
425 box-shadow: 0 18px 44px rgb(15 17 19 / 22%);
426 overflow: hidden;
427}
428.voice-danmaku-history-head {
429 display: flex;
430 align-items: center;
431 gap: 8px;
432 flex-shrink: 0;
433 padding: 9px 11px;
434 border-bottom: 1px solid var(--shell-hairline);
435}
436.voice-danmaku-history-title { font-size: 12.5px; font-weight: 600; }
437.voice-danmaku-history-head .voice-badge { margin-left: 0; }
438.voice-danmaku-history-close {
439 display: grid;
440 margin-left: auto;
441 width: 24px;
442 height: 24px;
443 place-items: center;
444 border-radius: 7px;
445 color: var(--shell-ink-muted);
446}
447.voice-danmaku-history-close:hover { background: var(--shell-active); color: var(--shell-ink); }
448.voice-danmaku-history-more {
449 flex-shrink: 0;
450 padding: 6px;
451 color: var(--shell-ink-muted);
452 font-size: 11.5px;
453 border-bottom: 1px solid var(--shell-hairline);
454 transition: background 150ms, color 150ms;
455}
456.voice-danmaku-history-more:hover { background: var(--shell-field); color: var(--shell-ink); }
457.voice-danmaku-history-list { min-height: 0; flex: 1; padding: 4px 0; overflow-y: auto; }
458.voice-danmaku-history-row {
459 display: grid;
460 grid-template-columns: 68px minmax(0, 1fr) auto;
461 gap: 8px;
462 align-items: baseline;
463 padding: 5px 11px;
464 font-size: 12.5px;
465}
466.voice-danmaku-history-row:hover { background: var(--shell-field); }
467.voice-danmaku-history-author {
468 overflow: hidden;
469 color: var(--shell-ink-muted);
470 font-weight: 500;
471 text-overflow: ellipsis;
472 white-space: nowrap;
473}
474.voice-danmaku-history-text { color: var(--shell-ink); overflow-wrap: anywhere; white-space: pre-wrap; }
475.voice-danmaku-history-time { flex: none; color: var(--shell-ink-muted); font-size: 11px; }
476.voice-danmaku-history-foot {
477 display: flex;
478 flex-shrink: 0;
479 align-items: center;
480 gap: 6px;
481 padding: 8px 11px;
482 border-top: 1px solid var(--shell-hairline);
483 color: var(--shell-ink-muted);
484 font-size: 12px;
485 cursor: pointer;
486}
487.voice-danmaku-history-foot input { accent-color: var(--primary); }
488.voice-control.is-active { background: var(--shell-tile); color: var(--shell-ink); }
489/* 全局通话条:可拖动的悬浮胶囊 */
490.voice-call-bar {
491 position: fixed;
492 z-index: 35;
493 right: 18px;
494 bottom: 18px;
495 display: flex;
496 align-items: center;
497 gap: 8px;
498 min-height: 44px;
499 max-width: calc(100vw - 24px);
500 padding: 0 10px 0 6px;
501 border: 1px solid var(--shell-hairline);
502 border-radius: 999px;
503 background: color-mix(in srgb, var(--shell-surface) 90%, transparent);
504 backdrop-filter: blur(12px);
505 box-shadow: 0 10px 30px rgb(15 17 19 / 14%);
506 cursor: grab;
507 touch-action: none;
508 user-select: none;
509}
510.voice-call-bar.is-moved { right: auto; bottom: auto; }
511.voice-call-bar.is-dragging { cursor: grabbing; box-shadow: 0 16px 40px rgb(15 17 19 / 22%); }
512.voice-call-grip { flex: none; width: 12px; height: 16px; color: var(--shell-ink-muted); opacity: .5; }
513.voice-call-status { display: flex; min-width: 0; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
514.voice-call-dot {
515 flex: none;
516 width: 7px;
517 height: 7px;
518 border-radius: 50%;
519 background: var(--primary);
520 box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 20%, transparent);
521}
522.voice-call-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
523.voice-call-notice { min-width: 0; flex: 1; max-width: 240px; color: var(--shell-ink-muted); font-size: 12px; }
524.voice-call-actions { display: flex; margin-left: auto; align-items: center; gap: 2px; }
525.voice-call-actions > button,
526.voice-call-return {
527 display: grid;
528 width: 32px;
529 height: 32px;
530 place-items: center;
531 border-radius: 50%;
532 color: var(--shell-ink);
533 transition: background 150ms, color 150ms;
534}
535.voice-call-actions > button:hover,
536.voice-call-return:hover { background: var(--shell-active); }
537.voice-call-actions > button:disabled { opacity: .4; }
538.voice-call-actions > button.is-off { color: var(--shell-ink-muted); }
539.voice-call-actions > button.is-hangup { color: var(--destructive); }
540.voice-call-actions > button.is-hangup:hover { background: color-mix(in srgb, var(--destructive) 14%, transparent); }
541.voice-call-resume {
542 display: inline-flex;
543 align-items: center;
544 gap: 5px;
545 padding: 5px 12px;
546 border-radius: 999px;
547 background: color-mix(in srgb, var(--warning-foreground) 16%, transparent);
548 color: var(--warning-foreground);
549 font-size: 12px;
550}
551
552@media (max-width: 760px) {
553 .voice-tiles { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; padding: 12px 10px; }
554 .voice-stage { height: min(36vh, 300px); margin: 10px 10px 0; }
555 .voice-hands { margin: 0 10px 10px; }
556 .voice-banners { padding: 10px 10px 0; }
557 .voice-controls { margin-bottom: 12px; }
558 .voice-call-bar { right: 10px; bottom: 10px; padding: 0 8px 0 5px; }
559 .voice-call-notice { display: none; }
560 .voice-danmaku { height: min(34%, 180px); }
561 .voice-danmaku-item { font-size: 12px; }
562}
563
564@media (prefers-reduced-motion: reduce) {
565 .voice-danmaku-item {
566 left: 12px;
567 animation: none;
568 }
569}
Modified web/src/features/chat/ui/chat-composer.tsx View web/src/features/chat/ui/chat-composer.tsx
@@ -43,6 +43,8 @@
4343export interface ComposerProps {
4444 composer: ComposerState
4545 roomLabel: string
46 /** Override the entry hint (voice channels post bullet comments). */
47 placeholder?: string
4648 /** Thread scope changes the placeholder and the reply affordance. */
4749 threadRoot?: ChatMessage
4850 /** Quote-reply target, kept out of the text itself. */
@@ -65,6 +67,7 @@
6567export function ChatComposer({
6668 composer,
6769 roomLabel,
70 placeholder,
6871 replyTo,
6972 onCancelReply,
7073 editing,
@@ -312,7 +315,7 @@
312315 ref={boxRef}
313316 value={text}
314317 rows={1}
315 placeholder={`发消息给 ${roomLabel},分享你的想法…`}
318 placeholder={placeholder ?? `发消息给 ${roomLabel},分享你的想法…`}
316319 aria-label="消息输入框"
317320 aria-describedby={helpId}
318321 spellCheck
Modified web/src/features/chat/ui/chat-room.tsx View web/src/features/chat/ui/chat-room.tsx
@@ -9,8 +9,9 @@
99 * working over HTTP while realtime is reconnecting;
1010 * - a room that is gone (deleted, or access revoked mid-read) replaces the column
1111 * with the reason instead of an empty transcript that looks like "no messages";
12 * - voice channels have no text API at all, so they say so rather than showing a
13 * composer that cannot send.
12 * - voice channels keep the call surface on top and reuse the same text API:
13 * posts fly over the call as bullet comments, and the header switches to the
14 * full transcript whenever someone wants to read it in order.
1415 */
1516import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1617import {
@@ -21,13 +22,13 @@
2122 Lock,
2223 LogOut,
2324 MessageSquare,
25 Phone,
2426 Pin,
2527 RefreshCw,
2628 Search,
2729 Settings2,
2830 Star,
2931 Users,
30 Wifi,
3132 WifiOff,
3233} from 'lucide-react'
3334import {
@@ -42,6 +43,8 @@
4243 isDmKind,
4344 isVoiceRoom,
4445 leaveRoom as leaveRoomApi,
46 listVoiceParticipants,
47 type VoiceParticipant,
4548 searchMessages,
4649 updateRoom,
4750 updateRoomSetting,
@@ -66,11 +69,15 @@
6669 ThreadsPanel,
6770} from './side-panel'
6871import { IconAction, Popover, UserAvatar } from './parts'
72import { VoiceRoomPanel } from './voice/voice-room-panel'
73import { VoiceDanmakuInput } from './voice/voice-danmaku-input'
74import { useVoiceSession } from '@/shared/voice'
6975import { useComposer } from '../model/use-composer'
7076import { useRoomData } from '../model/use-room-data'
7177import { useRoomStream } from '../model/use-room-stream'
7278import type { ChatViewState } from '../model/view-state'
7379import './chat-polish.css'
80import './voice/voice.css'
7481
7582interface SearchState {
7683 query: string
@@ -122,16 +129,31 @@
122129 const room = data.room.data
123130 const kind = room?.kind ?? hint?.kind
124131 const voice = isVoiceRoom(kind)
132 // Which half of a voice channel the column shows: the call (with bullet
133 // comments) or the plain transcript. The call keeps running either way.
134 const [voicePane, setVoicePane] = useState<'call' | 'text'>('call')
125135 const dm = isDmKind(kind) || spaceName === '@me'
126136 const memberCount = room?.member_count ?? 0
127137 const slowModeSeconds = room?.rate_limit_per_user ?? 0
128138 const isOwner = Boolean(viewerId && room && room.owner_id === viewerId)
129139 const canModerateRoom = canManageSpace || isOwner
130140
141 // Voice rooms replace the transcript with the call surface; the participant
142 // list is webhook-backed, so the room re-reads it whenever a chat event says
143 // the room changed while a call is live.
144 const voiceState = useVoiceSession()
145 const voiceParticipants = useAsync(
146 () => (voice ? listVoiceParticipants(roomId).catch(() => [] as VoiceParticipant[]) : Promise.resolve([] as VoiceParticipant[])),
147 // The call status is a dependency: joining or leaving changes who is in
148 // the room, and neither produces a chat event on its own. The raw status is
149 // used (not a joined/not-joined flag) so the refetch also re-runs on
150 // connecting -> connected, when the participant actually appears.
151 [voice, roomId, version, room?.kind, voiceState.status],
152 )
153
131154 const stream = useRoomStream({
132155 roomId,
133156 viewerId,
134 enabled: !voice,
135157 slowModeSeconds,
136158 onUnreadChanged,
137159 onRoomEvent: kind => {
@@ -150,7 +172,7 @@
150172 const editing = state.editId ? findRow(stream.state.rows, state.editId) : undefined
151173 const composer = useComposer({
152174 roomId,
153 enabled: !voice && !stream.state.accessRevoked,
175 enabled: !stream.state.accessRevoked,
154176 editingId: editing?.id ?? null,
155177 editingContent: editing?.content ?? '',
156178 onSubmitted: (content, attachments) =>
@@ -248,11 +270,11 @@
248270 onJump: messageId => onStateChange({ jumpId: messageId }),
249271 canModerate: message =>
250272 canModerateRoom || Boolean(viewerId && message.author_id === viewerId),
251 canParticipate: !voice && !stream.state.accessRevoked,
273 canParticipate: !stream.state.accessRevoked,
252274 threadOf: messageId => data.threadOf(messageId),
253275 nameOf,
254276 }),
255 [canModerateRoom, data, nameOf, onStateChange, openThread, stream, voice, viewerId],
277 [canModerateRoom, data, nameOf, onStateChange, openThread, stream, viewerId],
256278 )
257279
258280 /**
@@ -344,6 +366,22 @@
344366 </p>
345367 </div>
346368 <div className="chat-header-actions">
369 {voice ? (
370 <IconAction
371 label={voicePane === 'call' ? '文字记录' : '通话视图'}
372 active={voicePane === 'text'}
373 onClick={() => setVoicePane(value => (value === 'call' ? 'text' : 'call'))}
374 >
375 {voicePane === 'call' ? (
376 <MessageSquare className="h-[17px] w-[17px]" />
377 ) : (
378 <Phone className="h-[17px] w-[17px]" />
379 )}
380 <span className="chat-action-label">
381 {voicePane === 'call' ? '文字记录' : '通话视图'}
382 </span>
383 </IconAction>
384 ) : null}
347385 <IconAction
348386 label="搜索消息"
349387 className="chat-search-action"
@@ -459,14 +497,38 @@
459497 </button>
460498 </div>
461499 ) : null}
462 {voice ? (
463 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
464 <Wifi className="h-6 w-6 text-shell-ink-muted" />
465 <p className="text-[14px] font-semibold">这是语音频道</p>
466 <p className="max-w-sm text-[12.5px] leading-[1.6] text-shell-ink-muted">
467 语音聊天暂未开放。想继续交流,可以前往左侧的文字频道。
468 </p>
469 </div>
500 {voice && voicePane === 'call' ? (
501 <>
502 <VoiceRoomPanel
503 key={roomId}
504 roomId={roomId}
505 roomName={title}
506 spaceName={spaceName}
507 isStage={kind === 'Stage'}
508 participants={voiceParticipants.data ?? []}
509 danmakuMessages={stream.state.rows}
510 danmakuSeeded={stream.state.seeded}
511 danmakuHasOlder={stream.state.hasOlder}
512 onLoadOlderDanmaku={stream.loadOlder}
513 nameOf={nameOf}
514 viewerId={viewerId}
515 canModerate={canModerateRoom}
516 />
517 <VoiceDanmakuInput
518 value={composer.text}
519 onChange={composer.setText}
520 disabled={stream.state.accessRevoked}
521 notice={
522 composer.blocked ??
523 (stream.slowModeRemaining > 0 ? '慢速模式 ' + stream.slowModeRemaining + 's' : null)
524 }
525 onSend={() => {
526 void composer.submit().then(ok => {
527 if (ok) onStateChange({ replyToId: undefined, editId: undefined })
528 })
529 }}
530 />
531 </>
470532 ) : (
471533 <Transcript
472534 state={stream.state}
@@ -495,7 +557,7 @@
495557 spaceName={spaceName}
496558 />
497559 )}
498 {voice ? null : (
560 {voice && voicePane === 'call' ? null : (
499561 <div className="chat-composer-band">
500562 <ChatComposer
501563 composer={composer}
Modified web/src/features/chat/index.ts View web/src/features/chat/index.ts
@@ -5,4 +5,5 @@
55// Consumers import from '@/features/chat', never from deeper files.
66export { ChatRoom, type ChatRoomProps } from './ui/chat-room'
77export { prefetchRoomView } from './model/prefetch'
8export { VoiceCallBar } from './ui/voice/voice-call-bar'
89export type { ChatPanelKind, ChatViewState } from './model/view-state'
Modified web/src/features/space-management/ui/space-header.tsx View web/src/features/space-management/ui/space-header.tsx
@@ -388,6 +388,7 @@
388388 options: [
389389 { value: 'Text', label: '文字频道' },
390390 { value: 'Voice', label: '语音频道' },
391 { value: 'Stage', label: '舞台频道' },
391392 ],
392393 },
393394 {
@@ -518,7 +519,7 @@
518519 )
519520}
520521
521type CreateRoomKind = 'Text' | 'Voice'
522type CreateRoomKind = 'Text' | 'Voice' | 'Stage'
522523type AudienceValue = 'Everyone' | 'FollowingOnly' | 'Nobody'
523524
524525function MenuItem({
Modified web/src/pages/workspace/ui/sidebar-nav.tsx View web/src/pages/workspace/ui/sidebar-nav.tsx
@@ -14,6 +14,7 @@
1414} from 'lucide-react'
1515import { moveRoom, moveSpaceDir } from '@/features/space-management'
1616import { ApiFailure } from '@/shared/lib'
17import { getVoiceSpaceStates, isVoiceRoom } from '@/entities/chat'
1718import { betweenPositions, STRIDE } from '../model/nav-order'
1819import { prefetchBoardView, prefetchRepoView, prefetchRoomView } from '../model/prefetch'
1920import type { BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
@@ -107,6 +108,33 @@
107108 return () => clearTimeout(timer)
108109 }, [notice])
109110
111 // Live voice occupancy for the channel rows. The webhook writes it into
112 // Redis on the server, so a 10s poll is enough for a sidebar badge and much
113 // cheaper than a socket subscription per row.
114 const [voiceCounts, setVoiceCounts] = useState<Record<string, number>>({})
115 useEffect(() => {
116 // The personal scope has no space-scoped voice endpoint.
117 if (spaceName === '@me') return
118 let cancelled = false
119 const load = async () => {
120 try {
121 const states = await getVoiceSpaceStates(spaceName)
122 if (cancelled) return
123 const next: Record<string, number> = {}
124 for (const room of states.rooms ?? []) next[room.room_id] = room.count
125 setVoiceCounts(next)
126 } catch {
127 // A missing voice surface must never break navigation.
128 }
129 }
130 void load()
131 const timer = setInterval(() => void load(), 10_000)
132 return () => {
133 cancelled = true
134 clearInterval(timer)
135 }
136 }, [spaceName])
137
110138 /* ------------------------------------------------------ view model ----- */
111139
112140 const flat: { node: DirNode; parent: string | null }[] = []
@@ -192,7 +220,7 @@
192220 position: edit ? edit.position : room.position,
193221 unread: room.unread_count,
194222 favorite: room.favorite,
195 voice: room.kind === 'Voice',
223 voice: isVoiceRoom(room.kind),
196224 })
197225 }
198226 // Tail of the root level, in API order and deliberately *not* draggable:
@@ -448,6 +476,7 @@
448476 onToggle={() => setCollapsed(current => (current.has(view.id) ? without(current, view.id) : new Set([...current, view.id])))}
449477 handlers={handlersFor(view)}
450478 onHover={hoverPrefetch}
479 voiceCount={spaceName === '@me' ? undefined : voiceCounts[view.id]}
451480 />,
452481 )
453482 if (isFolder && !isCollapsed) rows.push(...renderLevel(view.id, depth + 1, new Set([...seen, view.id])))
@@ -531,6 +560,7 @@
531560 onToggle,
532561 handlers,
533562 onHover,
563 voiceCount,
534564}: {
535565 view: View
536566 depth: number
@@ -542,6 +572,8 @@
542572 handlers: Record<string, unknown>
543573 /** Hover prefetch, delayed so a cursor sweeping the list fires nothing. */
544574 onHover: (view: View) => void
575 /** Live participants in this voice channel, when > 0. */
576 voiceCount?: number
545577}) {
546578 const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
547579 const cancelHover = () => clearTimeout(hoverTimer.current)
@@ -607,6 +639,16 @@
607639 {view.favorite ? (
608640 <Star className="ml-auto h-3 w-3 shrink-0 fill-[#f0b232] text-[#f0b232]" aria-label="已收藏" />
609641 ) : null}
642 {view.voice && (voiceCount ?? 0) > 0 ? (
643 <span
644 className={`shrink-0 rounded-full bg-[#2ecc71]/18 px-1.5 text-[11px] font-semibold leading-[20px] text-[#1a7f37] ${
645 view.favorite ? '' : 'ml-auto'
646 }`}
647 title={`${voiceCount} 人在通话`}
648 >
649 {voiceCount}
650 </span>
651 ) : null}
610652 {view.unread > 0 ? (
611653 <span
612654 className={`shrink-0 rounded-full bg-shell-tile px-1.5 text-[11px] font-semibold leading-[20px] text-shell-ink ${
Modified web/src/pages/workspace/ui/workspace-shell.tsx View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -4,6 +4,7 @@
44import { BoardView } from './board/board-view'
55import { ChatLayout } from './layout/chat-layout'
66import { ChatView } from './chat/chat-view'
7import { VoiceCallBar } from '@/features/chat'
78import { RepoView } from './repo/repo-view'
89import { ApiFailure } from '@/shared/lib'
910import { useShellRoute } from '../model/workspace-route'
@@ -184,6 +185,8 @@
184185 {settingsModal}
185186 {spaceSettingsModal}
186187 <NotifyToaster />
188
189 <VoiceCallBar />
187190 <GlobalSearchCommand />
188191 </>
189192 )
@@ -204,6 +207,8 @@
204207 {settingsModal}
205208 {spaceSettingsModal}
206209 <NotifyToaster />
210
211 <VoiceCallBar />
207212 <GlobalSearchCommand />
208213 </>
209214 )
@@ -217,6 +222,8 @@
217222 {settingsModal}
218223 {spaceSettingsModal}
219224 <NotifyToaster />
225
226 <VoiceCallBar />
220227 <GlobalSearchCommand />
221228 </>
222229 )
@@ -239,6 +246,8 @@
239246 {settingsModal}
240247 {spaceSettingsModal}
241248 <NotifyToaster />
249
250 <VoiceCallBar />
242251 <GlobalSearchCommand />
243252 </>
244253 )
@@ -254,6 +263,8 @@
254263 {settingsModal}
255264 {spaceSettingsModal}
256265 <NotifyToaster />
266
267 <VoiceCallBar />
257268 <GlobalSearchCommand />
258269 </>
259270 )
Modified web/src/shared/api/apiDefinitions.ts View web/src/shared/api/apiDefinitions.ts
@@ -142,6 +142,7 @@
142142 'chat.post_via_webhook': ['POST', '/chat/hooks/{token}'],
143143 'chat.accept_room_invite': ['POST', '/chat/invites/accept'],
144144 'chat.reject_room_invite': ['POST', '/chat/invites/reject'],
145 'chat.livekit_webhook': ['POST', '/chat/livekit/webhook'],
145146 'chat.set_presence': ['PUT', '/chat/presence'],
146147 'chat.list_reminders': ['GET', '/chat/reminders'],
147148 'chat.cancel_reminder': ['DELETE', '/chat/reminders/{reminder_id}'],
@@ -194,6 +195,18 @@
194195 'chat.get_room_setting': ['GET', '/chat/rooms/{room_id}/settings'],
195196 'chat.update_room_setting': ['PUT', '/chat/rooms/{room_id}/settings'],
196197 'chat.list_threads': ['GET', '/chat/rooms/{room_id}/threads'],
198 'chat.list_hands': ['GET', '/chat/rooms/{room_id}/voice/hands'],
199 'chat.raise_hand': ['POST', '/chat/rooms/{room_id}/voice/hands'],
200 'chat.cancel_hand': ['DELETE', '/chat/rooms/{room_id}/voice/hands/@me'],
201 'chat.approve_hand': ['POST', '/chat/rooms/{room_id}/voice/hands/{user_id}/approve'],
202 'chat.deny_hand': ['POST', '/chat/rooms/{room_id}/voice/hands/{user_id}/deny'],
203 'chat.revoke_hand': ['POST', '/chat/rooms/{room_id}/voice/hands/{user_id}/revoke'],
204 'chat.voice_participants': ['GET', '/chat/rooms/{room_id}/voice/participants'],
205 'chat.kick_participant': ['DELETE', '/chat/rooms/{room_id}/voice/participants/{user_id}'],
206 'chat.moderate_mute': ['POST', '/chat/rooms/{room_id}/voice/participants/{user_id}/mute'],
207 'chat.start_recording': ['POST', '/chat/rooms/{room_id}/voice/recording/start'],
208 'chat.stop_recording': ['POST', '/chat/rooms/{room_id}/voice/recording/stop'],
209 'chat.voice_token': ['POST', '/chat/rooms/{room_id}/voice/token'],
197210 'chat.list_webhooks': ['GET', '/chat/rooms/{room_id}/webhooks'],
198211 'chat.create_webhook': ['POST', '/chat/rooms/{room_id}/webhooks'],
199212 'chat.delete_webhook': ['DELETE', '/chat/rooms/{room_id}/webhooks/{webhook_id}'],
@@ -208,6 +221,7 @@
208221 'chat.list_space_read_states': ['GET', '/chat/spaces/{space_name}/read-states'],
209222 'chat.list_space_rooms': ['GET', '/chat/spaces/{space_name}/rooms'],
210223 'chat.create_room': ['POST', '/chat/spaces/{space_name}/rooms'],
224 'chat.voice_space_states': ['GET', '/chat/spaces/{space_name}/voice-states'],
211225 'chat.list_my_threads': ['GET', '/chat/threads'],
212226 'chat.get_thread': ['GET', '/chat/threads/{thread_id}'],
213227 'chat.update_thread': ['PATCH', '/chat/threads/{thread_id}'],
@@ -215,6 +229,7 @@
215229 'chat.unfollow_thread': ['DELETE', '/chat/threads/{thread_id}/members/@me'],
216230 'chat.mark_thread_read': ['PUT', '/chat/threads/{thread_id}/read-state'],
217231 'chat.get_presence': ['GET', '/chat/users/{user_id}/presence'],
232 'chat.voice_config': ['GET', '/chat/voice/config'],
218233 'graphql.graphql_get': ['GET', '/graphql'],
219234 'graphql.graphql_post': ['POST', '/graphql'],
220235 'imports.list_imports': ['GET', '/imports/{space_name}'],
Modified web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts

Diff too large to display.

Added web/src/shared/voice/index.ts View web/src/shared/voice/index.ts
@@ -0,0 +1,3 @@
1export { voiceSession, participantTracks, storedVoiceSession } from './session'
2export type { JoinOptions, StoredSession, VoiceSessionSnapshot, VoiceStatus } from './session'
3export { useVoiceSession } from './react'
Added web/src/shared/voice/react.ts View web/src/shared/voice/react.ts
@@ -0,0 +1,13 @@
1import { useCallback, useSyncExternalStore } from 'react'
2import { voiceSession, type VoiceSessionSnapshot } from './session'
3
4const INITIAL: VoiceSessionSnapshot = voiceSession.getSnapshot()
5
6export function useVoiceSession(): VoiceSessionSnapshot {
7 const subscribe = useCallback(
8 (onChange: () => void) => voiceSession.subscribe(() => onChange()),
9 [],
10 )
11 const getSnapshot = useCallback(() => voiceSession.getSnapshot(), [])
12 return useSyncExternalStore(subscribe, getSnapshot, () => INITIAL)
13}
Added web/src/shared/voice/session.ts View web/src/shared/voice/session.ts
@@ -0,0 +1,451 @@
1/**
2 * The app-wide voice session: exactly one LiveKit Room for the whole tab,
3 * mirroring the realtime hub's "module singleton, not a React provider"
4 * decision. It outlives every route, so switching to a repository or board
5 * keeps the call up and the global call bar can drive it from anywhere.
6 *
7 * Design notes:
8 * - A refresh restores the room from sessionStorage, but always muted: the
9 * browser will not autoplay audio without a gesture, and silently rejoining a
10 * call with an open microphone is worse than one extra click.
11 * - Track/participant changes are published as a single immutable snapshot so
12 * useSyncExternalStore consumers re-render only on real state changes.
13 * - Failures are values (notice), never throws: the room view stays usable when
14 * LiveKit is down, exactly like the chat socket's status banner.
15 */
16import {
17 ConnectionState,
18 DisconnectReason,
19 Room,
20 RoomEvent,
21 Track,
22 type LocalParticipant,
23 type Participant,
24 type RemoteParticipant,
25} from 'livekit-client'
26
27/** Persisted so a refresh can rejoin the same channel. */
28const STORAGE_KEY = 'gitdata.voice.session'
29
30export type VoiceStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'failed'
31
32export interface VoiceSessionSnapshot {
33 status: VoiceStatus
34 roomId: string | null
35 /** Channel name and owning space, so the global bar can label and link back. */
36 roomLabel: string | null
37 spaceName: string | null
38 /** Set when a moderator removed this participant: the bar explains it. */
39 notice: string | null
40 micEnabled: boolean
41 cameraEnabled: boolean
42 screenShareEnabled: boolean
43 /** False until the browser has been allowed to play remote audio. */
44 audioPlaybackAllowed: boolean
45 speaking: boolean
46 /** True once a publish permission flip landed (Stage approval). */
47 canPublish: boolean
48 isStage: boolean
49 hasRaisedHand: boolean
50}
51
52export interface StoredSession {
53 roomId: string
54 roomLabel?: string
55 spaceName?: string
56}
57
58export interface JoinOptions {
59 roomId: string
60 /** Channel name for the global call bar. */
61 roomLabel?: string
62 /** Owning space slug, so "回到语音频道" can rebuild the route. */
63 spaceName?: string
64 url: string
65 token: string
66 canPublish: boolean
67 isStage: boolean
68 /** Voice channels join with the microphone on; Stage audiences stay muted. */
69 startWithMic: boolean
70}
71
72const INITIAL: VoiceSessionSnapshot = {
73 status: 'idle',
74 roomId: null,
75 roomLabel: null,
76 spaceName: null,
77 notice: null,
78 micEnabled: false,
79 cameraEnabled: false,
80 screenShareEnabled: false,
81 audioPlaybackAllowed: true,
82 speaking: false,
83 canPublish: true,
84 isStage: false,
85 hasRaisedHand: false,
86}
87
88type Listener = (snapshot: VoiceSessionSnapshot) => void
89
90class VoiceSession {
91 private room: Room | null = null
92 private snapshot: VoiceSessionSnapshot = INITIAL
93 private readonly listeners = new Set<Listener>()
94 private readonly participantNames = new Map<string, string>()
95 /** Distinguishes a user-initiated teardown from an ICE/signal drop. */
96 private leaving = false
97 /** True once the media peer connection reached the connected state. */
98 private hadMedia = false
99
100 getSnapshot(): VoiceSessionSnapshot {
101 return this.snapshot
102 }
103
104 subscribe(listener: Listener): () => void {
105 this.listeners.add(listener)
106 return () => {
107 this.listeners.delete(listener)
108 }
109 }
110
111 /** Display name for a participant identity (falls back to the raw identity). */
112 nameOf(identity: string): string {
113 return this.participantNames.get(identity) ?? identity
114 }
115
116 private patch(updates: Partial<VoiceSessionSnapshot>): void {
117 const next = { ...this.snapshot, ...updates }
118 let changed = false
119 for (const key of Object.keys(updates) as (keyof VoiceSessionSnapshot)[]) {
120 if (this.snapshot[key] !== next[key]) {
121 changed = true
122 break
123 }
124 }
125 if (!changed) return
126 this.snapshot = next
127 for (const listener of [...this.listeners]) listener(next)
128 }
129
130 /** True when this tab is currently inside roomId. */
131 isIn(roomId: string): boolean {
132 return this.snapshot.roomId === roomId && this.snapshot.status !== 'idle'
133 }
134
135 /** The room a page load should offer to restore, if any. */
136 static stored(): StoredSession | null {
137 try {
138 const raw = sessionStorage.getItem(STORAGE_KEY)
139 if (!raw) return null
140 const parsed = JSON.parse(raw) as StoredSession
141 return parsed.roomId ? parsed : null
142 } catch {
143 return null
144 }
145 }
146
147 private persist(roomId: string | null): void {
148 try {
149 if (!roomId) {
150 sessionStorage.removeItem(STORAGE_KEY)
151 return
152 }
153 const payload: StoredSession = {
154 roomId,
155 ...(this.snapshot.roomLabel ? { roomLabel: this.snapshot.roomLabel } : {}),
156 ...(this.snapshot.spaceName ? { spaceName: this.snapshot.spaceName } : {}),
157 }
158 sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
159 } catch {
160 // Private mode / disabled storage: the call still works, only the
161 // refresh restore is lost.
162 }
163 }
164
165 /**
166 * Browsers only reveal their own host candidates once the page may touch the
167 * microphone, and a LiveKit host on WSL can only pair through loopback here.
168 * Asking up front puts the prompt on the user's click and makes the media
169 * channel reachable; a denial still joins in listen-only mode.
170 */
171 private async primeMicrophone(): Promise<void> {
172 try {
173 const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
174 for (const track of stream.getTracks()) track.stop()
175 } catch {
176 this.patch({ notice: '未授予麦克风权限,将以只听模式加入' })
177 }
178 }
179
180 private async microphoneAlreadyGranted(): Promise<boolean> {
181 try {
182 const status = await navigator.permissions.query({ name: 'microphone' as PermissionName })
183 return status.state === 'granted'
184 } catch {
185 return false
186 }
187 }
188
189 async join(options: JoinOptions): Promise<void> {
190 if (this.room && this.snapshot.roomId === options.roomId) return
191 if (this.room) await this.leave()
192 const room = new Room({ adaptiveStream: true, dynacast: true })
193 this.room = room
194 this.leaving = false
195 this.hadMedia = false
196 this.patch({
197 status: 'connecting',
198 roomId: options.roomId,
199 roomLabel: options.roomLabel ?? null,
200 spaceName: options.spaceName ?? null,
201 notice: null,
202 canPublish: options.canPublish,
203 isStage: options.isStage,
204 micEnabled: false,
205 cameraEnabled: false,
206 screenShareEnabled: false,
207 speaking: false,
208 })
209 this.bind(room)
210
211 try {
212 if (options.canPublish && (options.startWithMic || (await this.microphoneAlreadyGranted()))) {
213 await this.primeMicrophone()
214 }
215 await room.connect(options.url, options.token)
216 this.patch({ status: 'connected' })
217 this.persist(options.roomId)
218 if (options.canPublish && options.startWithMic) {
219 await this.setMicrophone(true)
220 }
221 } catch (error) {
222 const message = error instanceof Error ? error.message : '语音连接失败'
223 this.room = null
224 this.leaving = true
225 this.patch({
226 status: 'failed',
227 roomId: null,
228 micEnabled: false,
229 cameraEnabled: false,
230 screenShareEnabled: false,
231 speaking: false,
232 notice: this.snapshot.notice ?? describeVoiceFailure(message),
233 })
234 throw error
235 }
236 }
237
238 private bind(room: Room): void {
239 const refreshSelf = () => {
240 if (this.room !== room) return
241 const local = room.localParticipant
242 this.captureNames([...room.remoteParticipants.values()], local)
243 this.patch({
244 micEnabled: local.isMicrophoneEnabled,
245 cameraEnabled: local.isCameraEnabled,
246 screenShareEnabled: local.isScreenShareEnabled,
247 canPublish: local.permissions?.canPublish ?? this.snapshot.canPublish,
248 speaking: local.isSpeaking,
249 })
250 }
251
252 room
253 .on(RoomEvent.ConnectionStateChanged, (state: ConnectionState) => {
254 if (state === ConnectionState.Connected) {
255 this.hadMedia = true
256 this.patch({ status: 'connected' })
257 } else if (state === ConnectionState.Reconnecting) this.patch({ status: 'reconnecting' })
258 })
259 .on(RoomEvent.Reconnected, () => this.patch({ status: 'connected', notice: null }))
260 .on(RoomEvent.MediaDevicesError, (error: Error) => {
261 this.patch({ notice: describeVoiceFailure(error.message) })
262 })
263 .on(RoomEvent.ParticipantConnected, () => refreshSelf())
264 .on(RoomEvent.ParticipantDisconnected, () => refreshSelf())
265 .on(RoomEvent.LocalTrackPublished, () => refreshSelf())
266 .on(RoomEvent.LocalTrackUnpublished, () => refreshSelf())
267 .on(RoomEvent.TrackMuted, () => refreshSelf())
268 .on(RoomEvent.TrackUnmuted, () => refreshSelf())
269 .on(RoomEvent.ActiveSpeakersChanged, () => refreshSelf())
270 .on(RoomEvent.ParticipantPermissionsChanged, () => refreshSelf())
271 .on(RoomEvent.AudioPlaybackStatusChanged, () => {
272 this.patch({ audioPlaybackAllowed: room.canPlaybackAudio })
273 })
274 .on(RoomEvent.Disconnected, reason => {
275 // The SDK also disconnects on pagehide, and that must NOT clear the
276 // stored session — the next page load is expected to restore the call.
277 // Only reasons that make a return impossible drop the stored room.
278 const kicked = reason === DisconnectReason.PARTICIPANT_REMOVED
279 const leftForGood =
280 kicked ||
281 reason === DisconnectReason.ROOM_DELETED ||
282 reason === DisconnectReason.DUPLICATE_IDENTITY
283 const intentional = this.leaving || reason === DisconnectReason.CLIENT_INITIATED
284 const retainedNotice = this.snapshot.notice
285 if (this.room === room) this.room = null
286 if (leftForGood) this.persist(null)
287 this.patch({
288 status: intentional || leftForGood ? 'idle' : 'failed',
289 roomId: null,
290 roomLabel: leftForGood ? null : this.snapshot.roomLabel,
291 spaceName: leftForGood ? null : this.snapshot.spaceName,
292 micEnabled: false,
293 cameraEnabled: false,
294 screenShareEnabled: false,
295 speaking: false,
296 notice: kicked
297 ? '你已被管理员移出通话'
298 : reason === DisconnectReason.DUPLICATE_IDENTITY
299 ? '该账号已在其他地方加入通话'
300 : retainedNotice
301 ? retainedNotice
302 : intentional
303 ? null
304 : this.hadMedia
305 ? '语音连接已中断,请点击重新加入'
306 : MEDIA_UNREACHABLE_NOTICE,
307 })
308 })
309 refreshSelf()
310 }
311
312 private captureNames(remotes: RemoteParticipant[], local: LocalParticipant | null): void {
313 for (const participant of remotes) {
314 if (participant.name) this.participantNames.set(participant.identity, participant.name)
315 }
316 if (local?.name) this.participantNames.set(local.identity, local.name)
317 }
318
319 async leave(): Promise<void> {
320 const room = this.room
321 this.leaving = true
322 this.room = null
323 this.persist(null)
324 this.patch({
325 status: 'idle',
326 roomId: null,
327 roomLabel: null,
328 spaceName: null,
329 micEnabled: false,
330 cameraEnabled: false,
331 screenShareEnabled: false,
332 speaking: false,
333 })
334 if (!room) return
335 try {
336 await room.disconnect()
337 } catch {
338 // Already gone; nothing useful to report.
339 }
340 }
341
342 async setMicrophone(enabled: boolean): Promise<void> {
343 const room = this.room
344 if (!room) return
345 try {
346 await room.localParticipant.setMicrophoneEnabled(enabled)
347 this.patch({ micEnabled: enabled })
348 } catch (error) {
349 this.patch({
350 notice: error instanceof Error ? error.message : '无法访问麦克风',
351 })
352 }
353 }
354
355 async setCamera(enabled: boolean): Promise<void> {
356 const room = this.room
357 if (!room) return
358 try {
359 await room.localParticipant.setCameraEnabled(enabled)
360 this.patch({ cameraEnabled: enabled })
361 } catch (error) {
362 this.patch({
363 notice: error instanceof Error ? error.message : '无法访问摄像头',
364 })
365 }
366 }
367
368 async setScreenShare(enabled: boolean): Promise<void> {
369 const room = this.room
370 if (!room) return
371 try {
372 await room.localParticipant.setScreenShareEnabled(enabled, { audio: true })
373 this.patch({ screenShareEnabled: enabled })
374 } catch (error) {
375 this.patch({
376 notice: error instanceof Error ? error.message : '无法共享屏幕',
377 })
378 }
379 }
380
381 /** Browsers block remote audio until a gesture; the call bar calls this. */
382 async resumeAudio(): Promise<void> {
383 const room = this.room
384 if (!room) return
385 try {
386 await room.startAudio()
387 this.patch({ audioPlaybackAllowed: room.canPlaybackAudio })
388 } catch {
389 this.patch({ notice: '浏览器仍阻止了音频播放,请检查站点声音权限' })
390 }
391 }
392
393 markHandRaised(raised: boolean): void {
394 this.patch({ hasRaisedHand: raised })
395 }
396
397 clearNotice(): void {
398 this.patch({ notice: null })
399 }
400
401 /** LiveKit Room handle for the room view (tile rendering). */
402 getRoom(): Room | null {
403 return this.room
404 }
405}
406
407const MEDIA_UNREACHABLE_NOTICE =
408 '语音通道建立失败:浏览器连不上 LiveKit 媒体端口(UDP 50000 / TCP 7881),请确认 LiveKit 已启动后重试'
409
410/** Turn the SDK's English transport errors into something actionable. */
411function describeVoiceFailure(message: string): string {
412 const text = message.toLowerCase()
413 if (
414 text.includes('pc connection') ||
415 text.includes('peerconnection') ||
416 text.includes('ice') ||
417 text.includes('timeout')
418 ) {
419 return MEDIA_UNREACHABLE_NOTICE
420 }
421 if (text.includes('permission') || text.includes('notallowed') || text.includes('denied')) {
422 return '浏览器拒绝了麦克风/摄像头权限,请在地址栏恢复后重试'
423 }
424 if (text.includes('token')) {
425 return '语音令牌无效或已过期,请重新加入'
426 }
427 return message
428}
429
430export const voiceSession = new VoiceSession()
431
432/** The persisted call for this tab, if any — the refresh-restore entry point. */
433export function storedVoiceSession(): StoredSession | null {
434 return VoiceSession.stored()
435}
436
437/** Stable helper for the tile list so both panels derive it identically. */
438export function participantTracks(participant: Participant): {
439 camera?: Track
440 screen?: Track
441} {
442 let camera: Track | undefined
443 let screen: Track | undefined
444 for (const publication of participant.trackPublications.values()) {
445 const track = publication.track
446 if (!track) continue
447 if (track.source === Track.Source.ScreenShare) screen = track
448 else if (track.source === Track.Source.Camera) camera = track
449 }
450 return { camera, screen }
451}
Modified web/src/shared/ws/protocol.ts View web/src/shared/ws/protocol.ts
@@ -164,6 +164,9 @@
164164 'message_pinned',
165165 'reaction_updated',
166166 'typing',
167 'voice_presence',
168 'voice_hands',
169 'voice_moderation',
167170 'room_updated',
168171 'member_joined',
169172 'member_left',
@@ -203,6 +206,9 @@
203206 /** Discard the indicator this many seconds after it was seen. */
204207 ttl_secs: number
205208 }
209 | { kind: 'voice_presence'; room_id: string }
210 | { kind: 'voice_hands'; room_id: string }
211 | { kind: 'voice_moderation'; room_id: string; user_id: string; action: string }
206212 | { kind: 'room_updated'; room_id: string }
207213 | { kind: 'member_joined'; room_id: string; user_id: string }
208214 | { kind: 'member_left'; room_id: string; user_id: string }
Modified web/bun.lock View web/bun.lock
@@ -1,5 +1,5 @@
11{
2 "lockfileVersion": 2,
2 "lockfileVersion": 1,
33 "configVersion": 1,
44 "workspaces": {
55 "": {
@@ -9,6 +9,7 @@
99 "@tailwindcss/vite": "^4.3.3",
1010 "alova": "^3.5.5",
1111 "dompurify": "^3.4.15",
12 "livekit-client": "2.22.3",
1213 "lucide-react": "^1.39.0",
1314 "marked": "^18.0.13",
1415 "react": "^19.2.8",
@@ -37,6 +38,8 @@
3738
3839 "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
3940
41 "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="],
42
4043 "@clack/core": ["@clack/core@0.4.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA=="],
4144
4245 "@clack/prompts": ["@clack/prompts@0.9.1", "", { "dependencies": { "@clack/core": "0.4.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg=="],
@@ -111,81 +114,85 @@
111114
112115 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
113116
117 "@livekit/mutex": ["@livekit/mutex@1.1.1", "", {}, "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw=="],
118
119 "@livekit/protocol": ["@livekit/protocol@1.50.4", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA=="],
120
114121 "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
115122
116123 "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
117124
118125 "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
119126
120 "@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="],
127 "@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="],
121128
122 "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.80.0", "", { "os": "android", "cpu": "arm" }, "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA=="],
129 "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.83.0", "", { "os": "android", "cpu": "arm" }, "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA=="],
123130
124 "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.80.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw=="],
131 "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.83.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg=="],
125132
126 "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.80.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA=="],
133 "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.83.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ=="],
127134
128 "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.80.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA=="],
135 "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.83.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA=="],
129136
130 "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.80.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg=="],
137 "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.83.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w=="],
131138
132 "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.80.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA=="],
139 "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.83.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw=="],
133140
134 "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.80.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ=="],
141 "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.83.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA=="],
135142
136 "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.80.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A=="],
143 "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.83.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg=="],
137144
138 "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.80.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ=="],
145 "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.83.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew=="],
139146
140 "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.80.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A=="],
147 "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.83.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g=="],
141148
142 "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.80.0", "", { "os": "linux", "cpu": "none" }, "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA=="],
149 "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.83.0", "", { "os": "linux", "cpu": "none" }, "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw=="],
143150
144 "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.80.0", "", { "os": "linux", "cpu": "none" }, "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ=="],
151 "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.83.0", "", { "os": "linux", "cpu": "none" }, "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw=="],
145152
146 "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.80.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw=="],
153 "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.83.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ=="],
147154
148 "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.80.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A=="],
155 "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.83.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw=="],
149156
150 "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.80.0", "", { "os": "linux", "cpu": "x64" }, "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ=="],
157 "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.83.0", "", { "os": "linux", "cpu": "x64" }, "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ=="],
151158
152 "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.80.0", "", { "os": "none", "cpu": "arm64" }, "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA=="],
159 "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.83.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA=="],
153160
154 "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.80.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ=="],
161 "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.83.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg=="],
155162
156 "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.80.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw=="],
163 "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.83.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA=="],
157164
158 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.80.0", "", { "os": "win32", "cpu": "x64" }, "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q=="],
165 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.83.0", "", { "os": "win32", "cpu": "x64" }, "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q=="],
159166
160 "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="],
167 "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="],
161168
162 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="],
169 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="],
163170
164 "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="],
171 "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw=="],
165172
166 "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="],
173 "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw=="],
167174
168 "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="],
175 "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g=="],
169176
170 "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="],
177 "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.9", "", { "os": "linux", "cpu": "arm" }, "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw=="],
171178
172 "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="],
179 "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw=="],
173180
174 "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="],
181 "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA=="],
175182
176 "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="],
183 "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw=="],
177184
178 "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="],
185 "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ=="],
179186
180 "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="],
187 "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q=="],
181188
182 "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="],
189 "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ=="],
183190
184 "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="],
191 "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.9", "", { "os": "none", "cpu": "arm64" }, "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg=="],
185192
186 "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="],
193 "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww=="],
187194
188 "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="],
195 "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="],
189196
190197 "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
191198
@@ -221,11 +228,13 @@
221228
222229 "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
223230
224 "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
231 "@types/dom-mediacapture-record": ["@types/dom-mediacapture-record@1.0.22", "", {}, "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw=="],
225232
226 "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
233 "@types/node": ["@types/node@24.13.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug=="],
227234
228 "@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="],
235 "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
236
237 "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
229238
230239 "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
231240
@@ -245,7 +254,7 @@
245254
246255 "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
247256
248 "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
257 "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="],
249258
250259 "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
251260
@@ -285,15 +294,15 @@
285294
286295 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
287296
297 "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
298
288299 "effector": ["effector@23.4.4", "", {}, "sha512-QkZboRN28K/iwxigDhlJcI3ux3aNbt8kYGGH/GkqWG0OlGeyuBhb7PdM89Iu+ogV8Lmz16xIlwnXR2UNWI6psg=="],
289300
290301 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
291302
292 "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
293
294303 "empathic": ["empathic@1.1.0", "", {}, "sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA=="],
295304
296 "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
305 "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
297306
298307 "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
299308
@@ -305,6 +314,8 @@
305314
306315 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
307316
317 "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
318
308319 "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
309320
310321 "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
@@ -339,7 +350,7 @@
339350
340351 "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
341352
342 "ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="],
353 "ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="],
343354
344355 "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
345356
@@ -367,6 +378,8 @@
367378
368379 "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
369380
381 "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="],
382
370383 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
371384
372385 "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
@@ -399,20 +412,26 @@
399412
400413 "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
401414
415 "livekit-client": ["livekit-client@2.22.3", "", { "dependencies": { "@livekit/mutex": "1.1.1", "@livekit/protocol": "1.50.4", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", "machina": "^7.0.0", "sdp-transform": "^2.15.0", "tslib": "2.8.1", "typed-emitter": "^2.1.0", "webrtc-adapter": "9.0.6" }, "peerDependencies": { "@types/dom-mediacapture-record": "^1" } }, "sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg=="],
416
402417 "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
403418
404419 "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
405420
406421 "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
407422
423 "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
424
408425 "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
409426
410 "lucide-react": ["lucide-react@1.39.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8nXoEwvqqIsF927NBWXODa4bfMrcUeEb/9sgpwFqg0gUjgn3j5Hznk+v7STmPgZ2iQ11JKlbQGdFRuTOwvYkA=="],
427 "lucide-react": ["lucide-react@1.46.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA=="],
411428
412 "marked": ["marked@18.0.13", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw=="],
429 "machina": ["machina@7.0.1", "", {}, "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA=="],
413430
414431 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
415432
433 "marked": ["marked@18.0.13", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw=="],
434
416435 "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
417436
418437 "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -425,7 +444,7 @@
425444
426445 "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
427446
428 "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
447 "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
429448
430449 "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
431450
@@ -451,7 +470,7 @@
451470
452471 "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
453472
454 "oxlint": ["oxlint@1.80.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.80.0", "@oxlint/binding-android-arm64": "1.80.0", "@oxlint/binding-darwin-arm64": "1.80.0", "@oxlint/binding-darwin-x64": "1.80.0", "@oxlint/binding-freebsd-x64": "1.80.0", "@oxlint/binding-linux-arm-gnueabihf": "1.80.0", "@oxlint/binding-linux-arm-musleabihf": "1.80.0", "@oxlint/binding-linux-arm64-gnu": "1.80.0", "@oxlint/binding-linux-arm64-musl": "1.80.0", "@oxlint/binding-linux-ppc64-gnu": "1.80.0", "@oxlint/binding-linux-riscv64-gnu": "1.80.0", "@oxlint/binding-linux-riscv64-musl": "1.80.0", "@oxlint/binding-linux-s390x-gnu": "1.80.0", "@oxlint/binding-linux-x64-gnu": "1.80.0", "@oxlint/binding-linux-x64-musl": "1.80.0", "@oxlint/binding-openharmony-arm64": "1.80.0", "@oxlint/binding-win32-arm64-msvc": "1.80.0", "@oxlint/binding-win32-ia32-msvc": "1.80.0", "@oxlint/binding-win32-x64-msvc": "1.80.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA=="],
473 "oxlint": ["oxlint@1.83.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.83.0", "@oxlint/binding-android-arm64": "1.83.0", "@oxlint/binding-darwin-arm64": "1.83.0", "@oxlint/binding-darwin-x64": "1.83.0", "@oxlint/binding-freebsd-x64": "1.83.0", "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", "@oxlint/binding-linux-arm-musleabihf": "1.83.0", "@oxlint/binding-linux-arm64-gnu": "1.83.0", "@oxlint/binding-linux-arm64-musl": "1.83.0", "@oxlint/binding-linux-ppc64-gnu": "1.83.0", "@oxlint/binding-linux-riscv64-gnu": "1.83.0", "@oxlint/binding-linux-riscv64-musl": "1.83.0", "@oxlint/binding-linux-s390x-gnu": "1.83.0", "@oxlint/binding-linux-x64-gnu": "1.83.0", "@oxlint/binding-linux-x64-musl": "1.83.0", "@oxlint/binding-openharmony-arm64": "1.83.0", "@oxlint/binding-win32-arm64-msvc": "1.83.0", "@oxlint/binding-win32-ia32-msvc": "1.83.0", "@oxlint/binding-win32-x64-msvc": "1.83.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ=="],
455474
456475 "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
457476
@@ -473,9 +492,9 @@
473492
474493 "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
475494
476 "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
495 "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
477496
478 "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
497 "prettier": ["prettier@3.9.7", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-T3mF5P7HFzZVLDQuCUNtJj6WK1pcpLMweMNUVwGb01bLNkH9AkKxtZvmInw8K6bnn2O3d6/5RHl6zSHiBAD0Og=="],
479498
480499 "prexit": ["prexit@2.3.0", "", {}, "sha512-mX+LIbtS0anKtl2PykYabxninwloblUQMRO6CubeSmjxb+kKlATuJoH9UeN8NLE4TgIEFWfBXw7V3GkWbBrSmg=="],
481500
@@ -483,13 +502,13 @@
483502
484503 "rate-limiter-flexible": ["rate-limiter-flexible@5.0.5", "", {}, "sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ=="],
485504
486 "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
505 "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
487506
488 "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
507 "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
489508
490 "react-router": ["react-router@7.18.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA=="],
509 "react-router": ["react-router@7.18.4", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ=="],
491510
492 "react-router-dom": ["react-router-dom@7.18.3", "", { "dependencies": { "react-router": "7.18.3" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg=="],
511 "react-router-dom": ["react-router-dom@7.18.4", "", { "dependencies": { "react-router": "7.18.4" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw=="],
493512
494513 "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
495514
@@ -505,13 +524,19 @@
505524
506525 "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
507526
508 "rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
527 "rolldown": ["rolldown@1.2.9", "", { "dependencies": { "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-x64-msvc": "1.2.9" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg=="],
509528
510529 "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
511530
531 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
532
512533 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
513534
514 "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
535 "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
536
537 "sdp": ["sdp@3.2.2", "", {}, "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA=="],
538
539 "sdp-transform": ["sdp-transform@2.15.0", "", { "bin": { "sdp-verify": "checker.js" } }, "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw=="],
515540
516541 "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
517542
@@ -565,6 +590,10 @@
565590
566591 "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
567592
593 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
594
595 "typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="],
596
568597 "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
569598
570599 "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
@@ -575,7 +604,7 @@
575604
576605 "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
577606
578 "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="],
607 "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
579608
580609 "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
581610
@@ -583,6 +612,8 @@
583612
584613 "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
585614
615 "webrtc-adapter": ["webrtc-adapter@9.0.6", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog=="],
616
586617 "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
587618
588619 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -613,9 +644,9 @@
613644
614645 "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
615646
616 "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
647 "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="],
617648
618 "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
649 "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
619650
620651 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
621652
Modified web/openapi.json View web/openapi.json

Diff too large to display.

Modified web/package.json View web/package.json
@@ -16,6 +16,7 @@
1616 "@tailwindcss/vite": "^4.3.3",
1717 "alova": "^3.5.5",
1818 "dompurify": "^3.4.15",
19 "livekit-client": "2.22.3",
1920 "lucide-react": "^1.39.0",
2021 "marked": "^18.0.13",
2122 "react": "^19.2.8",
Modified Cargo.lock View Cargo.lock
@@ -1012,6 +1012,15 @@
10121012]
10131013
10141014[[package]]
1015name = "block2"
1016version = "0.6.2"
1017source = "registry+https://github.com/rust-lang/crates.io-index"
1018checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
1019dependencies = [
1020 "objc2",
1021]
1022
1023[[package]]
10151024name = "blowfish"
10161025version = "0.10.0"
10171026source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1154,6 +1163,12 @@
11541163]
11551164
11561165[[package]]
1166name = "cesu8"
1167version = "1.1.0"
1168source = "registry+https://github.com/rust-lang/crates.io-index"
1169checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
1170
1171[[package]]
11571172name = "cfg-if"
11581173version = "1.0.4"
11591174source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1191,6 +1206,8 @@
11911206 "futures",
11921207 "hmac 0.13.0",
11931208 "http 0.2.12",
1209 "livekit-api",
1210 "livekit-protocol",
11941211 "models",
11951212 "rand 0.8.7",
11961213 "redis",
@@ -1911,6 +1928,21 @@
19111928]
19121929
19131930[[package]]
1931name = "device-info"
1932version = "0.1.1"
1933source = "registry+https://github.com/rust-lang/crates.io-index"
1934checksum = "a2ca8e71544c1b67dcdbc2699ab258828aff985e5bc8d5f6b486d90d7df2f848"
1935dependencies = [
1936 "core-foundation",
1937 "jni",
1938 "libc",
1939 "thiserror 2.0.20",
1940 "wasm-bindgen",
1941 "web-sys",
1942 "windows-sys 0.59.0",
1943]
1944
1945[[package]]
19141946name = "digest"
19151947version = "0.10.7"
19161948source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1956,6 +1988,16 @@
19561988]
19571989
19581990[[package]]
1991name = "dispatch2"
1992version = "0.3.1"
1993source = "registry+https://github.com/rust-lang/crates.io-index"
1994checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
1995dependencies = [
1996 "bitflags 2.13.1",
1997 "objc2",
1998]
1999
2000[[package]]
19592001name = "displaydoc"
19602002version = "0.2.7"
19612003source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2202,7 +2244,7 @@
22022244 "config",
22032245 "email",
22042246 "http-body-util",
2205 "jsonwebtoken",
2247 "jsonwebtoken 9.3.1",
22062248 "rand 0.8.7",
22072249 "redis",
22082250 "sea-orm",
@@ -2339,6 +2381,12 @@
23392381]
23402382
23412383[[package]]
2384name = "fixedbitset"
2385version = "0.4.2"
2386source = "registry+https://github.com/rust-lang/crates.io-index"
2387checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
2388
2389[[package]]
23422390name = "flate2"
23432391version = "1.1.9"
23442392source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4121,7 +4169,7 @@
41214169 "tokio",
41224170 "tokio-rustls",
41234171 "tower-service",
4124 "webpki-roots",
4172 "webpki-roots 1.0.9",
41254173]
41264174
41274175[[package]]
@@ -4806,6 +4854,24 @@
48064854
48074855[[package]]
48084856name = "itertools"
4857version = "0.11.0"
4858source = "registry+https://github.com/rust-lang/crates.io-index"
4859checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
4860dependencies = [
4861 "either",
4862]
4863
4864[[package]]
4865name = "itertools"
4866version = "0.12.1"
4867source = "registry+https://github.com/rust-lang/crates.io-index"
4868checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
4869dependencies = [
4870 "either",
4871]
4872
4873[[package]]
4874name = "itertools"
48094875version = "0.14.0"
48104876source = "registry+https://github.com/rust-lang/crates.io-index"
48114877checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
@@ -4888,6 +4954,50 @@
48884954]
48894955
48904956[[package]]
4957name = "jni"
4958version = "0.21.1"
4959source = "registry+https://github.com/rust-lang/crates.io-index"
4960checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
4961dependencies = [
4962 "cesu8",
4963 "cfg-if",
4964 "combine",
4965 "jni-sys 0.3.1",
4966 "log 0.4.33",
4967 "thiserror 1.0.69",
4968 "walkdir",
4969 "windows-sys 0.45.0",
4970]
4971
4972[[package]]
4973name = "jni-sys"
4974version = "0.3.1"
4975source = "registry+https://github.com/rust-lang/crates.io-index"
4976checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
4977dependencies = [
4978 "jni-sys 0.4.1",
4979]
4980
4981[[package]]
4982name = "jni-sys"
4983version = "0.4.1"
4984source = "registry+https://github.com/rust-lang/crates.io-index"
4985checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
4986dependencies = [
4987 "jni-sys-macros",
4988]
4989
4990[[package]]
4991name = "jni-sys-macros"
4992version = "0.4.1"
4993source = "registry+https://github.com/rust-lang/crates.io-index"
4994checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
4995dependencies = [
4996 "quote",
4997 "syn 2.0.119",
4998]
4999
5000[[package]]
48915001name = "jobserver"
48925002version = "0.1.35"
48935003source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4924,6 +5034,21 @@
49245034]
49255035
49265036[[package]]
5037name = "jsonwebtoken"
5038version = "10.4.0"
5039source = "registry+https://github.com/rust-lang/crates.io-index"
5040checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
5041dependencies = [
5042 "base64 0.22.1",
5043 "getrandom 0.2.17",
5044 "js-sys",
5045 "serde",
5046 "serde_json",
5047 "signature 2.2.0",
5048 "zeroize",
5049]
5050
5051[[package]]
49275052name = "keccak"
49285053version = "0.2.1"
49295054source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5086,6 +5211,114 @@
50865211checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
50875212
50885213[[package]]
5214name = "livekit-api"
5215version = "0.7.1"
5216source = "registry+https://github.com/rust-lang/crates.io-index"
5217checksum = "6b1120c4ea5a95f28d8aed783c49fb5ebd57cb9ad3b8270c583c71404bbd4372"
5218dependencies = [
5219 "base64 0.21.7",
5220 "http 1.5.0",
5221 "livekit-protocol",
5222 "livekit-region",
5223 "livekit-signaling",
5224 "livekit-token",
5225 "log 0.4.33",
5226 "pbjson-types",
5227 "prost 0.12.6",
5228 "rand 0.9.5",
5229 "reqwest 0.12.28",
5230 "serde",
5231 "serde_json",
5232 "sha2 0.10.9",
5233 "thiserror 2.0.20",
5234 "tokio",
5235 "url 2.5.8",
5236]
5237
5238[[package]]
5239name = "livekit-common"
5240version = "0.1.3"
5241source = "registry+https://github.com/rust-lang/crates.io-index"
5242checksum = "ed16f156f1b6669cb17d7a910ab97d61d01ef24a7b3d14caade3edde9b02339f"
5243dependencies = [
5244 "livekit-protocol",
5245]
5246
5247[[package]]
5248name = "livekit-net"
5249version = "0.1.3"
5250source = "registry+https://github.com/rust-lang/crates.io-index"
5251checksum = "f9528a26c807f49d3fa6d075c01db5fd77bf32454b1e700c530fb8c670892008"
5252dependencies = [
5253 "async-trait",
5254 "http 1.5.0",
5255 "log 0.4.33",
5256 "reqwest 0.12.28",
5257 "tokio-tungstenite",
5258 "url 2.5.8",
5259]
5260
5261[[package]]
5262name = "livekit-protocol"
5263version = "0.7.13"
5264source = "registry+https://github.com/rust-lang/crates.io-index"
5265checksum = "a76389797ee45f81577ee00d9f8a5d816bec8f9284988374bd20cdc1119c13e4"
5266dependencies = [
5267 "pbjson",
5268 "pbjson-types",
5269 "prost 0.12.6",
5270 "serde",
5271]
5272
5273[[package]]
5274name = "livekit-region"
5275version = "0.1.1"
5276source = "registry+https://github.com/rust-lang/crates.io-index"
5277checksum = "8e94621b7d7cbb4544cd568c8e632852cc174d383055b0e77f4638eb91e43d3b"
5278dependencies = [
5279 "serde",
5280]
5281
5282[[package]]
5283name = "livekit-signaling"
5284version = "0.1.2"
5285source = "registry+https://github.com/rust-lang/crates.io-index"
5286checksum = "1c1a9b518fa5434cae1dd679e60254070f203241c0319f239fd836e216960f36"
5287dependencies = [
5288 "base64 0.21.7",
5289 "device-info",
5290 "flate2",
5291 "http 1.5.0",
5292 "livekit-common",
5293 "livekit-net",
5294 "livekit-protocol",
5295 "livekit-region",
5296 "log 0.4.33",
5297 "os_info",
5298 "parking_lot",
5299 "prost 0.12.6",
5300 "serde_json",
5301 "thiserror 2.0.20",
5302 "tokio",
5303 "url 2.5.8",
5304]
5305
5306[[package]]
5307name = "livekit-token"
5308version = "0.1.2"
5309source = "registry+https://github.com/rust-lang/crates.io-index"
5310checksum = "bc9c914fdcfb38e7e48acfe5dfa1a64eb01a4e3f42c37f792e48b0705a8cc7c4"
5311dependencies = [
5312 "hmac 0.12.1",
5313 "jsonwebtoken 10.4.0",
5314 "livekit-protocol",
5315 "serde",
5316 "sha2 0.10.9",
5317 "signature 2.2.0",
5318 "thiserror 2.0.20",
5319]
5320
5321[[package]]
50895322name = "lock_api"
50905323version = "0.4.14"
50915324source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5414,6 +5647,12 @@
54145647]
54155648
54165649[[package]]
5650name = "multimap"
5651version = "0.10.1"
5652source = "registry+https://github.com/rust-lang/crates.io-index"
5653checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
5654
5655[[package]]
54175656name = "native-tls"
54185657version = "0.2.18"
54195658source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5605,19 +5844,178 @@
56055844]
56065845
56075846[[package]]
5608name = "object"
5609version = "0.39.1"
5847name = "objc2"
5848version = "0.6.4"
56105849source = "registry+https://github.com/rust-lang/crates.io-index"
5611checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
5850checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
56125851dependencies = [
5613 "memchr",
5852 "objc2-encode",
56145853]
56155854
56165855[[package]]
5617name = "once_cell"
5618version = "1.21.4"
5856name = "objc2-cloud-kit"
5857version = "0.3.2"
56195858source = "registry+https://github.com/rust-lang/crates.io-index"
5620checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
5859checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
5860dependencies = [
5861 "bitflags 2.13.1",
5862 "objc2",
5863 "objc2-foundation",
5864]
5865
5866[[package]]
5867name = "objc2-core-data"
5868version = "0.3.2"
5869source = "registry+https://github.com/rust-lang/crates.io-index"
5870checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
5871dependencies = [
5872 "objc2",
5873 "objc2-foundation",
5874]
5875
5876[[package]]
5877name = "objc2-core-foundation"
5878version = "0.3.2"
5879source = "registry+https://github.com/rust-lang/crates.io-index"
5880checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
5881dependencies = [
5882 "bitflags 2.13.1",
5883 "dispatch2",
5884 "objc2",
5885]
5886
5887[[package]]
5888name = "objc2-core-graphics"
5889version = "0.3.2"
5890source = "registry+https://github.com/rust-lang/crates.io-index"
5891checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
5892dependencies = [
5893 "bitflags 2.13.1",
5894 "dispatch2",
5895 "objc2",
5896 "objc2-core-foundation",
5897 "objc2-io-surface",
5898]
5899
5900[[package]]
5901name = "objc2-core-image"
5902version = "0.3.2"
5903source = "registry+https://github.com/rust-lang/crates.io-index"
5904checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
5905dependencies = [
5906 "objc2",
5907 "objc2-foundation",
5908]
5909
5910[[package]]
5911name = "objc2-core-location"
5912version = "0.3.2"
5913source = "registry+https://github.com/rust-lang/crates.io-index"
5914checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
5915dependencies = [
5916 "objc2",
5917 "objc2-foundation",
5918]
5919
5920[[package]]
5921name = "objc2-core-text"
5922version = "0.3.2"
5923source = "registry+https://github.com/rust-lang/crates.io-index"
5924checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
5925dependencies = [
5926 "bitflags 2.13.1",
5927 "objc2",
5928 "objc2-core-foundation",
5929 "objc2-core-graphics",
5930]
5931
5932[[package]]
5933name = "objc2-encode"
5934version = "4.1.0"
5935source = "registry+https://github.com/rust-lang/crates.io-index"
5936checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
5937
5938[[package]]
5939name = "objc2-foundation"
5940version = "0.3.2"
5941source = "registry+https://github.com/rust-lang/crates.io-index"
5942checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
5943dependencies = [
5944 "bitflags 2.13.1",
5945 "block2",
5946 "libc",
5947 "objc2",
5948 "objc2-core-foundation",
5949]
5950
5951[[package]]
5952name = "objc2-io-surface"
5953version = "0.3.2"
5954source = "registry+https://github.com/rust-lang/crates.io-index"
5955checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
5956dependencies = [
5957 "bitflags 2.13.1",
5958 "objc2",
5959 "objc2-core-foundation",
5960]
5961
5962[[package]]
5963name = "objc2-quartz-core"
5964version = "0.3.2"
5965source = "registry+https://github.com/rust-lang/crates.io-index"
5966checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
5967dependencies = [
5968 "bitflags 2.13.1",
5969 "objc2",
5970 "objc2-core-foundation",
5971 "objc2-foundation",
5972]
5973
5974[[package]]
5975name = "objc2-ui-kit"
5976version = "0.3.2"
5977source = "registry+https://github.com/rust-lang/crates.io-index"
5978checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
5979dependencies = [
5980 "bitflags 2.13.1",
5981 "block2",
5982 "objc2",
5983 "objc2-cloud-kit",
5984 "objc2-core-data",
5985 "objc2-core-foundation",
5986 "objc2-core-graphics",
5987 "objc2-core-image",
5988 "objc2-core-location",
5989 "objc2-core-text",
5990 "objc2-foundation",
5991 "objc2-quartz-core",
5992 "objc2-user-notifications",
5993]
5994
5995[[package]]
5996name = "objc2-user-notifications"
5997version = "0.3.2"
5998source = "registry+https://github.com/rust-lang/crates.io-index"
5999checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
6000dependencies = [
6001 "objc2",
6002 "objc2-foundation",
6003]
6004
6005[[package]]
6006name = "object"
6007version = "0.39.1"
6008source = "registry+https://github.com/rust-lang/crates.io-index"
6009checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
6010dependencies = [
6011 "memchr",
6012]
6013
6014[[package]]
6015name = "once_cell"
6016version = "1.21.4"
6017source = "registry+https://github.com/rust-lang/crates.io-index"
6018checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
56216019
56226020[[package]]
56236021name = "opaque-debug"
@@ -5715,7 +6113,7 @@
57156113 "opentelemetry-http",
57166114 "opentelemetry-proto",
57176115 "opentelemetry_sdk",
5718 "prost",
6116 "prost 0.14.4",
57196117 "reqwest 0.13.4",
57206118 "thiserror 2.0.20",
57216119]
@@ -5728,7 +6126,7 @@
57286126dependencies = [
57296127 "opentelemetry",
57306128 "opentelemetry_sdk",
5731 "prost",
6129 "prost 0.14.4",
57326130]
57336131
57346132[[package]]
@@ -5781,6 +6179,22 @@
57816179]
57826180
57836181[[package]]
6182name = "os_info"
6183version = "3.15.0"
6184source = "registry+https://github.com/rust-lang/crates.io-index"
6185checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
6186dependencies = [
6187 "android_system_properties",
6188 "log 0.4.33",
6189 "nix 0.31.3",
6190 "objc2",
6191 "objc2-foundation",
6192 "objc2-ui-kit",
6193 "serde",
6194 "windows-sys 0.61.2",
6195]
6196
6197[[package]]
57846198name = "ouroboros"
57856199version = "0.18.5"
57866200source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5971,6 +6385,43 @@
59716385checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
59726386
59736387[[package]]
6388name = "pbjson"
6389version = "0.6.0"
6390source = "registry+https://github.com/rust-lang/crates.io-index"
6391checksum = "1030c719b0ec2a2d25a5df729d6cff1acf3cc230bf766f4f97833591f7577b90"
6392dependencies = [
6393 "base64 0.21.7",
6394 "serde",
6395]
6396
6397[[package]]
6398name = "pbjson-build"
6399version = "0.6.2"
6400source = "registry+https://github.com/rust-lang/crates.io-index"
6401checksum = "2580e33f2292d34be285c5bc3dba5259542b083cfad6037b6d70345f24dcb735"
6402dependencies = [
6403 "heck 0.4.1",
6404 "itertools 0.11.0",
6405 "prost 0.12.6",
6406 "prost-types",
6407]
6408
6409[[package]]
6410name = "pbjson-types"
6411version = "0.6.0"
6412source = "registry+https://github.com/rust-lang/crates.io-index"
6413checksum = "18f596653ba4ac51bdecbb4ef6773bc7f56042dc13927910de1684ad3d32aa12"
6414dependencies = [
6415 "bytes",
6416 "chrono",
6417 "pbjson",
6418 "pbjson-build",
6419 "prost 0.12.6",
6420 "prost-build",
6421 "serde",
6422]
6423
6424[[package]]
59746425name = "pbkdf2"
59756426version = "0.13.0"
59766427source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6086,6 +6537,16 @@
60866537]
60876538
60886539[[package]]
6540name = "petgraph"
6541version = "0.6.5"
6542source = "registry+https://github.com/rust-lang/crates.io-index"
6543checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
6544dependencies = [
6545 "fixedbitset",
6546 "indexmap",
6547]
6548
6549[[package]]
60896550name = "pgvector"
60906551version = "0.4.2"
60916552source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6306,6 +6767,16 @@
63066767]
63076768
63086769[[package]]
6770name = "prettyplease"
6771version = "0.2.37"
6772source = "registry+https://github.com/rust-lang/crates.io-index"
6773checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
6774dependencies = [
6775 "proc-macro2",
6776 "syn 2.0.119",
6777]
6778
6779[[package]]
63096780name = "primefield"
63106781version = "0.14.0"
63116782source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6385,16 +6856,60 @@
63856856
63866857[[package]]
63876858name = "prost"
6859version = "0.12.6"
6860source = "registry+https://github.com/rust-lang/crates.io-index"
6861checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29"
6862dependencies = [
6863 "bytes",
6864 "prost-derive 0.12.6",
6865]
6866
6867[[package]]
6868name = "prost"
63886869version = "0.14.4"
63896870source = "registry+https://github.com/rust-lang/crates.io-index"
63906871checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
63916872dependencies = [
63926873 "bytes",
6393 "prost-derive",
6874 "prost-derive 0.14.4",
6875]
6876
6877[[package]]
6878name = "prost-build"
6879version = "0.12.6"
6880source = "registry+https://github.com/rust-lang/crates.io-index"
6881checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
6882dependencies = [
6883 "bytes",
6884 "heck 0.5.0",
6885 "itertools 0.12.1",
6886 "log 0.4.33",
6887 "multimap",
6888 "once_cell",
6889 "petgraph",
6890 "prettyplease",
6891 "prost 0.12.6",
6892 "prost-types",
6893 "regex",
6894 "syn 2.0.119",
6895 "tempfile",
63946896]
63956897
63966898[[package]]
63976899name = "prost-derive"
6900version = "0.12.6"
6901source = "registry+https://github.com/rust-lang/crates.io-index"
6902checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1"
6903dependencies = [
6904 "anyhow",
6905 "itertools 0.12.1",
6906 "proc-macro2",
6907 "quote",
6908 "syn 2.0.119",
6909]
6910
6911[[package]]
6912name = "prost-derive"
63986913version = "0.14.4"
63996914source = "registry+https://github.com/rust-lang/crates.io-index"
64006915checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
@@ -6407,6 +6922,15 @@
64076922]
64086923
64096924[[package]]
6925name = "prost-types"
6926version = "0.12.6"
6927source = "registry+https://github.com/rust-lang/crates.io-index"
6928checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0"
6929dependencies = [
6930 "prost 0.12.6",
6931]
6932
6933[[package]]
64106934name = "psm"
64116935version = "0.1.32"
64126936source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6828,7 +7352,7 @@
68287352 "wasm-bindgen",
68297353 "wasm-bindgen-futures",
68307354 "web-sys",
6831 "webpki-roots",
7355 "webpki-roots 1.0.9",
68327356]
68337357
68347358[[package]]
@@ -8027,7 +8551,7 @@
80278551 "tracing",
80288552 "url 2.5.8",
80298553 "uuid",
8030 "webpki-roots",
8554 "webpki-roots 1.0.9",
80318555]
80328556
80338557[[package]]
@@ -8665,8 +9189,12 @@
86659189dependencies = [
86669190 "futures-util",
86679191 "log 0.4.33",
9192 "rustls",
9193 "rustls-pki-types",
86689194 "tokio",
9195 "tokio-rustls",
86699196 "tungstenite",
9197 "webpki-roots 0.26.11",
86709198]
86719199
86729200[[package]]
@@ -9004,8 +9532,11 @@
90049532 "httparse",
90059533 "log 0.4.33",
90069534 "rand 0.9.5",
9535 "rustls",
9536 "rustls-pki-types",
90079537 "sha1 0.10.7",
90089538 "thiserror 2.0.20",
9539 "url 2.5.8",
90099540]
90109541
90119542[[package]]
@@ -9469,6 +10000,15 @@
946910000
947010001[[package]]
947110002name = "webpki-roots"
10003version = "0.26.11"
10004source = "registry+https://github.com/rust-lang/crates.io-index"
10005checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
10006dependencies = [
10007 "webpki-roots 1.0.9",
10008]
10009
10010[[package]]
10011name = "webpki-roots"
947210012version = "1.0.9"
947310013source = "registry+https://github.com/rust-lang/crates.io-index"
947410014checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
@@ -9616,6 +10156,15 @@
961610156
961710157[[package]]
961810158name = "windows-sys"
10159version = "0.45.0"
10160source = "registry+https://github.com/rust-lang/crates.io-index"
10161checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
10162dependencies = [
10163 "windows-targets 0.42.2",
10164]
10165
10166[[package]]
10167name = "windows-sys"
961910168version = "0.48.0"
962010169source = "registry+https://github.com/rust-lang/crates.io-index"
962110170checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
@@ -9652,6 +10201,21 @@
965210201
965310202[[package]]
965410203name = "windows-targets"
10204version = "0.42.2"
10205source = "registry+https://github.com/rust-lang/crates.io-index"
10206checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
10207dependencies = [
10208 "windows_aarch64_gnullvm 0.42.2",
10209 "windows_aarch64_msvc 0.42.2",
10210 "windows_i686_gnu 0.42.2",
10211 "windows_i686_msvc 0.42.2",
10212 "windows_x86_64_gnu 0.42.2",
10213 "windows_x86_64_gnullvm 0.42.2",
10214 "windows_x86_64_msvc 0.42.2",
10215]
10216
10217[[package]]
10218name = "windows-targets"
965510219version = "0.48.5"
965610220source = "registry+https://github.com/rust-lang/crates.io-index"
965710221checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
@@ -9692,6 +10256,12 @@
969210256
969310257[[package]]
969410258name = "windows_aarch64_gnullvm"
10259version = "0.42.2"
10260source = "registry+https://github.com/rust-lang/crates.io-index"
10261checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
10262
10263[[package]]
10264name = "windows_aarch64_gnullvm"
969510265version = "0.48.5"
969610266source = "registry+https://github.com/rust-lang/crates.io-index"
969710267checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
@@ -9704,6 +10274,12 @@
970410274
970510275[[package]]
970610276name = "windows_aarch64_msvc"
10277version = "0.42.2"
10278source = "registry+https://github.com/rust-lang/crates.io-index"
10279checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
10280
10281[[package]]
10282name = "windows_aarch64_msvc"
970710283version = "0.48.5"
970810284source = "registry+https://github.com/rust-lang/crates.io-index"
970910285checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
@@ -9716,6 +10292,12 @@
971610292
971710293[[package]]
971810294name = "windows_i686_gnu"
10295version = "0.42.2"
10296source = "registry+https://github.com/rust-lang/crates.io-index"
10297checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
10298
10299[[package]]
10300name = "windows_i686_gnu"
971910301version = "0.48.5"
972010302source = "registry+https://github.com/rust-lang/crates.io-index"
972110303checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
@@ -9734,6 +10316,12 @@
973410316
973510317[[package]]
973610318name = "windows_i686_msvc"
10319version = "0.42.2"
10320source = "registry+https://github.com/rust-lang/crates.io-index"
10321checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
10322
10323[[package]]
10324name = "windows_i686_msvc"
973710325version = "0.48.5"
973810326source = "registry+https://github.com/rust-lang/crates.io-index"
973910327checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
@@ -9746,6 +10334,12 @@
974610334
974710335[[package]]
974810336name = "windows_x86_64_gnu"
10337version = "0.42.2"
10338source = "registry+https://github.com/rust-lang/crates.io-index"
10339checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
10340
10341[[package]]
10342name = "windows_x86_64_gnu"
974910343version = "0.48.5"
975010344source = "registry+https://github.com/rust-lang/crates.io-index"
975110345checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
@@ -9758,6 +10352,12 @@
975810352
975910353[[package]]
976010354name = "windows_x86_64_gnullvm"
10355version = "0.42.2"
10356source = "registry+https://github.com/rust-lang/crates.io-index"
10357checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
10358
10359[[package]]
10360name = "windows_x86_64_gnullvm"
976110361version = "0.48.5"
976210362source = "registry+https://github.com/rust-lang/crates.io-index"
976310363checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
@@ -9770,6 +10370,12 @@
977010370
977110371[[package]]
977210372name = "windows_x86_64_msvc"
10373version = "0.42.2"
10374source = "registry+https://github.com/rust-lang/crates.io-index"
10375checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
10376
10377[[package]]
10378name = "windows_x86_64_msvc"
977310379version = "0.48.5"
977410380source = "registry+https://github.com/rust-lang/crates.io-index"
977510381checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
@@ -9926,6 +10532,20 @@
992610532version = "1.9.0"
992710533source = "registry+https://github.com/rust-lang/crates.io-index"
992810534checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
10535dependencies = [
10536 "zeroize_derive",
10537]
10538
10539[[package]]
10540name = "zeroize_derive"
10541version = "1.5.0"
10542source = "registry+https://github.com/rust-lang/crates.io-index"
10543checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
10544dependencies = [
10545 "proc-macro2",
10546 "quote",
10547 "syn 2.0.119",
10548]
992910549
993010550[[package]]
993110551name = "zerotrie"
Modified Cargo.toml View Cargo.toml
@@ -93,6 +93,8 @@
9393pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
9494lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls", "ring", "rustls-native-certs", "builder"] }
9595reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
96livekit-api = { version = "0.7.1", default-features = false, features = ["services", "webhooks", "rustls-tls-webpki-roots"] }
97livekit-protocol = "0.7.13"
9698anyhow = "1"
9799tracing-subscriber = { version = "0.3", features = ["env-filter"] }
98100jsonwebtoken = "9"
Modified openapi.json View openapi.json

Diff too large to display.