feat(voice): implement layout patching for participant windows and update related API
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified lib/chat/src/handlers/voice.rs lib/chat/src/handlers/voice.rs +10 −9 View lib/chat/src/handlers/voice.rs
@@ -12,8 +12,8 @@
1212use crate::errors::ApiError;
1313use crate::services::service::ChatService;
1414use crate::services::voice::{
15 HandDto, VoiceConfigDto, VoiceHandsDto, VoiceLayoutDto, VoiceModerationDto, VoiceParticipantDto,
16 VoiceRecordingDto, VoiceSpaceStatesDto, VoiceTokenDto,
15 HandDto, VoiceConfigDto, VoiceHandsDto, VoiceLayoutDto, VoiceLayoutPatchDto, VoiceLayoutSavedDto,
16 VoiceModerationDto, VoiceParticipantDto, VoiceRecordingDto, VoiceSpaceStatesDto, VoiceTokenDto,
1717};
1818
1919#[utoipa::path(
@@ -147,11 +147,11 @@
147147 put,
148148 path = "/chat/rooms/{room_id}/voice/layout",
149149 tag = "chat",
150 description = "保存参与者窗口布局并广播给房间内所有人,越界坐标会被夹回画布内。",
150 description = "按 key 合并保存参与者窗口布局并广播给房间内所有人:请求只带自己改动的那些格子(收回的列在 removed 里),越界坐标会被夹回画布内。",
151151 params(("room_id" = Uuid, Path, description = "room id")),
152 request_body = VoiceLayoutDto,
152 request_body = VoiceLayoutPatchDto,
153153 responses(
154 (status = 200, description = "layout saved", body = AppResponseData<NoData>),
154 (status = 200, description = "layout saved", body = AppResponseData<VoiceLayoutSavedDto>),
155155 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
156156 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
157157 (status = 403, description = "operation not allowed", body = AppResponseData<NoData>)
@@ -161,11 +161,12 @@
161161 State(service): State<ChatService>,
162162 session: Session,
163163 Path(room_id): Path<Uuid>,
164 Json(layout): Json<VoiceLayoutDto>,
165) -> Result<AppResponseData<NoData>, ApiError> {
164 Json(patch): Json<VoiceLayoutPatchDto>,
165) -> Result<AppResponseData<VoiceLayoutSavedDto>, ApiError> {
166166 let uid = require_uid(&session)?;
167 service.save_voice_layout(uid, room_id, layout).await?;
168 Ok(AppResponseData::empty())
167 Ok(AppResponseData::ok(
168 service.save_voice_layout(uid, room_id, patch).await?,
169 ))
169170}
170171
171172#[utoipa::path(
Modified lib/chat/src/services/voice.rs lib/chat/src/services/voice.rs +107 −44 View lib/chat/src/services/voice.rs
@@ -49,6 +49,10 @@
4949 pub x: f64,
5050 pub y: f64,
5151 pub w: f64,
52 /// 服务端写入时刻(秒)。请求里被忽略,响应里供客户端判断“谁更新”,
53 /// 这样并发拖拽时一个迟到的 GET 不会把刚摆好的窗口拽回旧位置。
54 #[serde(default)]
55 pub updated_at: i64,
5256}
5357
5458#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
@@ -58,6 +62,24 @@
5862 pub tiles: HashMap<String, VoiceTileDto>,
5963}
6064
65/// 布局写入是**按 key 合并**的补丁而不是整表替换:每个客户端只提交自己刚拖的
66/// 那一格,收回的格子里列在 `removed`,两个人同时摆不同窗口互不吞没。
67#[derive(Debug, Clone, Default, Deserialize, ToSchema)]
68pub struct VoiceLayoutPatchDto {
69 /// 需要新增或覆盖的窗口(key = 参与者用户 id)。
70 #[serde(default)]
71 pub tiles: HashMap<String, VoiceTileDto>,
72 /// 需要收回自动网格的窗口 key。
73 #[serde(default)]
74 pub removed: Vec<String>,
75}
76
77/// 本次写入的服务端时刻,客户端用它给本地副本盖戳。
78#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
79pub struct VoiceLayoutSavedDto {
80 pub updated_at: i64,
81}
82
6183/// 房间成员共享的窗口数量上限,避免布局被当成存储滥用。
6284const MAX_LAYOUT_TILES: usize = 64;
6385
@@ -87,6 +109,12 @@
87109 pub muted: bool,
88110}
89111
112/// 房间参与者快照。
113///
114/// 权威来源是 LiveKit 的 list_participants,Redis 只补主持人静音等本地状态;
115/// 这是「谁在这个频道里」的服务端视图,用于侧栏人数与未加入时的预览。
116/// 会中界面的发言高亮、静音图标与画面轨道一律直接读浏览器里的 Room,
117/// 因为本接口不是事件流,返回值天然是快照。
90118#[derive(Debug, Clone, Serialize, ToSchema)]
91119pub struct VoiceParticipantDto {
92120 pub user_id: Uuid,
@@ -95,6 +123,8 @@
95123 pub joined_at: i64,
96124 pub can_publish: bool,
97125 pub moderator_muted: bool,
126 /// 快照值:list_participants 不返回发言状态,因此这里恒为 false;
127 /// 实时发言指示由前端从 Room 的 activeSpeakers 读取。
98128 pub speaking: bool,
99129 pub tracks: Vec<VoiceTrackDto>,
100130}
@@ -153,6 +183,44 @@
153183 format!("chat:voice:{room_id}:hands")
154184}
155185
186/// 布局合并脚本:读写必须在 Redis 里原子完成,否则两个人同时摆不同窗口时,
187/// 后写的那次会带着自己读到的旧快照覆盖掉前一次的结果。
188const MERGE_LAYOUT_SCRIPT: &str = r"
189local key = KEYS[1]
190local stamp = tonumber(ARGV[1])
191local max_tiles = tonumber(ARGV[3])
192local ttl = tonumber(ARGV[4])
193local patch = cjson.decode(ARGV[2])
194local current = redis.call('GET', key)
195local layout = { tiles = {} }
196if current then
197 local ok, decoded = pcall(cjson.decode, current)
198 if ok and type(decoded) == 'table' and type(decoded.tiles) == 'table' then
199 layout = decoded
200 end
201end
202local removed = patch.removed
203if type(removed) == 'table' then
204 for _, id in ipairs(removed) do layout.tiles[id] = nil end
205end
206local added = patch.tiles
207if type(added) == 'table' then
208 for id, tile in pairs(added) do
209 layout.tiles[id] = {
210 x = tonumber(tile.x),
211 y = tonumber(tile.y),
212 w = tonumber(tile.w),
213 updated_at = stamp,
214 }
215 end
216end
217local count = 0
218for _ in pairs(layout.tiles) do count = count + 1 end
219if count > max_tiles then return { err = 'too many tiles' } end
220redis.call('SET', key, cjson.encode(layout), 'EX', ttl)
221return stamp
222";
223
156224pub fn layout_key(room_id: Uuid) -> String {
157225 format!("chat:voice:{room_id}:layout")
158226}
@@ -684,6 +752,11 @@
684752 /// 读取房间共享的窗口布局;没有存过就是空布局(前端回落到自动网格)。
685753 pub async fn voice_layout(&self, caller: Uuid, room_id: Uuid) -> Result<VoiceLayoutDto, ChatError> {
686754 require_voice_room(self, room_id, caller).await?;
755 self.voice_layout_raw(room_id).await
756 }
757
758 /// 纯读取,不做权限校验:调用方要么刚校验过,要么只用于合并写入。
759 async fn voice_layout_raw(&self, room_id: Uuid) -> Result<VoiceLayoutDto, ChatError> {
687760 let mut conn = self.state.redis.connection_clone();
688761 use export::RedisConnection;
689762 let key = layout_key(room_id);
@@ -698,64 +771,54 @@
698771 Ok(serde_json::from_str(&raw).unwrap_or_default())
699772 }
700773
701 /// 保存布局并广播给房间里的所有人。坐标一律夹到 0~1,越界写入不可能发生。
774 /// 按 key 合并布局补丁并广播给房间里的所有人。坐标一律夹到 0~1,越界写入
775 /// 不可能发生;返回写入时刻,让客户端能分辨“我发的”和“比我新的”。
702776 pub async fn save_voice_layout(
703777 &self,
704778 caller: Uuid,
705779 room_id: Uuid,
706 layout: VoiceLayoutDto,
707 ) -> Result<(), ChatError> {
780 patch: VoiceLayoutPatchDto,
781 ) -> Result<VoiceLayoutSavedDto, ChatError> {
708782 require_voice_room(self, room_id, caller).await?;
709 if layout.tiles.len() > MAX_LAYOUT_TILES {
710 return Err(ChatError::BadRequest(format!(
711 "at most {MAX_LAYOUT_TILES} windows can be placed"
712 )));
783 if patch.tiles.is_empty() && patch.removed.is_empty() {
784 return Err(ChatError::BadRequest("empty layout patch".to_string()));
713785 }
714 let tiles = layout
715 .tiles
716 .into_iter()
717 .map(|(id, tile)| {
718 (
719 id,
720 VoiceTileDto {
721 x: tile.x.clamp(0.0, 1.0),
722 y: tile.y.clamp(0.0, 1.0),
723 w: tile.w.clamp(0.05, 1.0),
724 },
725 )
726 })
727 .collect();
728 let value = serde_json::to_string(&VoiceLayoutDto { tiles })?;
786 let stamp = now_secs();
787 let payload = serde_json::json!({
788 "tiles": patch.tiles,
789 "removed": patch.removed,
790 })
791 .to_string();
729792 let ttl = self.config().livekit.presence_ttl_secs.max(60);
730793 let mut conn = self.state.redis.connection_clone();
731794 use export::RedisConnection;
732795 let key = layout_key(room_id);
733 let written: Result<(), redis::RedisError> = match &mut conn {
734 RedisConnection::Standalone(c) => {
735 let set: Result<(), redis::RedisError> = redis::cmd("SET")
736 .arg(&key)
737 .arg(&value)
738 .arg("EX")
739 .arg(ttl)
740 .query_async(c)
741 .await;
742 set
743 }
744 RedisConnection::Cluster(c) => {
745 redis::cmd("SET")
746 .arg(&key)
747 .arg(&value)
748 .arg("EX")
749 .arg(ttl)
750 .query_async(c)
751 .await
752 }
796 let mut command = redis::cmd("EVAL");
797 command
798 .arg(MERGE_LAYOUT_SCRIPT)
799 .arg(1_u8)
800 .arg(&key)
801 .arg(stamp)
802 .arg(&payload)
803 .arg(MAX_LAYOUT_TILES as i64)
804 .arg(ttl);
805 let written: Result<i64, redis::RedisError> = match &mut conn {
806 RedisConnection::Standalone(c) => command.clone().query_async(c).await,
807 RedisConnection::Cluster(c) => command.clone().query_async(c).await,
753808 };
754 written.map_err(ChatError::from)?;
809 let written = match written {
810 Ok(updated_at) => Ok(updated_at),
811 // The script signals a full map with an error reply; turn it back into
812 // the same 400 the previous whole-map write produced.
813 Err(error) if error.to_string().contains("too many tiles") => Err(ChatError::BadRequest(
814 format!("at most {MAX_LAYOUT_TILES} windows can be placed"),
815 )),
816 Err(error) => Err(ChatError::from(error)),
817 }?;
755818 self.realtime
756819 .publish_room(&RoomEvent::VoiceLayout { room_id })
757820 .await;
758 Ok(())
821 Ok(VoiceLayoutSavedDto { updated_at: written })
759822 }
760823
761824 /// 举手。Stage 频道之外、已有发言权都不允许。
Modified web/scripts/lint-nav.mjs web/scripts/lint-nav.mjs +1 −1 View web/scripts/lint-nav.mjs
@@ -23,7 +23,7 @@
2323 'src/pages/email-verification/',
2424 'src/pages/two-factor/',
2525]
26const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'pow-solver'])
26const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'pow-solver', 'livekit'])
2727
2828const PATTERNS = [
2929 ['raw <a> anchor', /<a[\s>]/],
Modified web/src/app/styles/workspace.css web/src/app/styles/workspace.css +15 −0 View web/src/app/styles/workspace.css
@@ -66,6 +66,21 @@
6666@media (min-width: 1024px) {
6767 .workspace-main :is(.shell-top-bar, .chat-header) { padding-right: calc(clamp(200px, 22vw, 300px) + 28px); }
6868}
69/*
70 * An open chat panel owns the same top-right corner, so the overlay steps left
71 * by the panel’s width (320px, 380px from `xl`) — otherwise it covers the
72 * panel’s own close button. Below 1201px the panel is a modal drawer behind a
73 * scrim, and the trigger hides with the page it covers.
74 */
75@media (max-width: 1200px) {
76 body:has(.chat-side-panel) .global-search-trigger { display: none; }
77}
78@media (min-width: 1201px) {
79 body:has(.chat-side-panel) .global-search-trigger { right: 336px; }
80}
81@media (min-width: 1280px) {
82 body:has(.chat-side-panel) .global-search-trigger { right: 396px; }
83}
6984.workspace-mobile-bar, .workspace-drawer-close, .workspace-scrim { display: none; }
7085.workspace-skip { position: fixed; left: 12px; top: -80px; z-index: 100; padding: 10px 16px; border-radius: 10px; color: white; background: var(--shell-action); }
7186.workspace-skip:focus { top: 12px; }
Modified web/src/app/bootstrap.tsx web/src/app/bootstrap.tsx +11 −49 View web/src/app/bootstrap.tsx
@@ -1,15 +1,15 @@
1import { useEffect, type ReactNode } from 'react'
1import 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'
4import { VoiceHost } from '@/shared/voice'
5import { installVoiceSession } from '@/features/chat'
66import { ApiErrorGate } from './api-error-gate'
77
88/**
99 * App-wide providers (Redux / QueryClient / theme / auth context, ...).
1010 *
1111 * Alova is provider-less, so the only wiring here is instance configuration.
12 * Keeping provider composition here lets \`app/index.tsx\` stay pure bootstrap.
12 * Keeping provider composition here lets `app/index.tsx` stay pure bootstrap.
1313 */
1414
1515/**
@@ -21,53 +21,15 @@
2121 * requests are still deduplicated by shareRequest.
2222 */
2323alovaInstance.options.cacheFor = { GET: 0 }
24
25let restoreStarted = false
2624
2725/**
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.
26 * Voice needs two app-level hooks because the call outlives every route:
27 * the session gets its credential source (and its refresh restore) from the
28 * chat feature, and `VoiceHost` feeds the same Room to the vendored LiveKit
29 * components so remote audio and the connection toast keep working while the
30 * user browses a repository or a board.
3731 */
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}
32installVoiceSession()
7133
7234export function AppProviders({ children }: { children: ReactNode }) {
7335 // The outage gate is deliberately above the router: it is a property of the
@@ -76,7 +38,7 @@
7638 return (
7739 <ApiErrorGate>
7840 <ThemeProvider>
79 <VoiceRestore />
41 <VoiceHost />
8042 {children}
8143 </ThemeProvider>
8244 </ApiErrorGate>
Modified web/src/app/index.tsx web/src/app/index.tsx +1 −0 View web/src/app/index.tsx
@@ -6,6 +6,7 @@
66import { prefetchWorkspaceBootstrap } from '@/pages/workspace'
77import { AppProviders } from './bootstrap'
88import { router } from './routes/router'
9import '@/shared/livekit/styles/dist/general/index.css'
910import './styles/index.css'
1011
1112initTheme()
Modified web/src/entities/chat/model/voice-api.ts web/src/entities/chat/model/voice-api.ts +35 −4 View web/src/entities/chat/model/voice-api.ts
@@ -25,7 +25,14 @@
2525export type VoiceModeration = NonNullable<AppResponseData_VoiceModerationDto['data']>
2626export type VoiceSpaceStates = NonNullable<AppResponseData_VoiceSpaceStatesDto['data']>
2727export type VoiceTile = VoiceTileDto
28/** Shared layout as stored on the server: every tile carries its write stamp. */
2829export type VoiceLayoutTiles = Record<string, VoiceTileDto>
30/** What a client may write: coordinates only, the stamp is server-owned. */
31export interface VoiceTileWrite {
32 x: number
33 y: number
34 w: number
35}
2936export type VoiceHandState = HandState
3037
3138export function getVoiceConfig(): Promise<VoiceConfigDto> {
@@ -94,6 +101,11 @@
94101/**
95102 * Shared participant-window layout. Coordinates are normalized against the
96103 * free canvas span, so the same layout lands inside the bounds on every screen.
104 *
105 * Writes are per-key patches: a client only ever submits the windows it moved,
106 * so two members dragging different windows cannot erase each other's work. The
107 * returned stamp is the server's write time, which lets the caller tell a fresh
108 * value from a GET that was already in flight when it dragged.
97109 */
98110export function getVoiceLayout(roomId: string): Promise<VoiceLayoutTiles> {
99111 return unwrapApi(
@@ -101,8 +113,27 @@
101113 ).then(data => data?.tiles ?? {})
102114}
103115
104export function saveVoiceLayout(roomId: string, tiles: VoiceLayoutTiles): Promise<void> {
105 return unwrapApi(
106 Apis.chat.save_voice_layout({ pathParams: { room_id: roomId }, data: { tiles } }),
107 ).then(() => undefined)
116/** Upsert the given windows; resolves to the server write stamp (seconds). */
117export async function saveVoiceLayoutTiles(
118 roomId: string,
119 tiles: Record<string, VoiceTileWrite>,
120): Promise<number> {
121 const data = await unwrapApi(
122 Apis.chat.save_voice_layout({
123 pathParams: { room_id: roomId },
124 data: { tiles, removed: [] },
125 }),
126 )
127 return data?.updated_at ?? 0
128}
129
130/** Drop the given windows back into the automatic grid. */
131export async function removeVoiceLayoutTiles(roomId: string, keys: string[]): Promise<number> {
132 const data = await unwrapApi(
133 Apis.chat.save_voice_layout({
134 pathParams: { room_id: roomId },
135 data: { tiles: {}, removed: keys },
136 }),
137 )
138 return data?.updated_at ?? 0
108139}
No newline at end of file
Modified web/src/entities/chat/index.ts web/src/entities/chat/index.ts +3 −1 View web/src/entities/chat/index.ts
@@ -18,10 +18,11 @@
1818 listVoiceHands,
1919 listVoiceParticipants,
2020 moderateVoiceMute,
21 removeVoiceLayoutTiles,
2122 raiseVoiceHand,
2223 requestVoiceToken,
2324 revokeVoiceHand,
24 saveVoiceLayout,
25 saveVoiceLayoutTiles,
2526} from './model/voice-api'
2627export type {
2728 VoiceConfigDto,
@@ -34,4 +35,5 @@
3435 VoiceLayoutTiles,
3536 VoiceToken,
3637 VoiceTile,
38 VoiceTileWrite,
3739} from './model/voice-api'
Modified web/src/features/chat/ui/voice/voice-call-bar.tsx web/src/features/chat/ui/voice/voice-call-bar.tsx +16 −0 View web/src/features/chat/ui/voice/voice-call-bar.tsx
@@ -20,6 +20,7 @@
2020 VideoOff,
2121 Volume2,
2222} from 'lucide-react'
23import { ConnectionQualityIndicator } from '@/shared/livekit'
2324import { useVoiceSession, voiceSession } from '@/shared/voice'
2425import { useT } from '@/shared/i18n'
2526
@@ -135,6 +136,7 @@
135136 : undefined
136137
137138 const connecting = session.status === 'connecting' || session.status === 'reconnecting'
139 const room = voiceSession.getRoom()
138140
139141 return (
140142 <div
@@ -166,6 +168,9 @@
166168 <span className="voice-call-dot" aria-hidden />
167169 )}
168170 <span className="voice-call-label">{roomLabel}</span>
171 {/* The official indicator reads the local participant's own quality, so
172 the bar says "your uplink is bad" instead of pretending all is well. */}
173 {room ? <ConnectionQualityIndicator participant={room.localParticipant} className="voice-call-quality" /> : null}
169174 </span>
170175
171176 {!session.audioPlaybackAllowed ? (
@@ -185,6 +190,17 @@
185190 </span>
186191 ) : null}
187192
193 {session.needsManualRejoin ? (
194 <button
195 type="button"
196 className="voice-call-resume"
197 onClick={() => void voiceSession.rejoin()}
198 >
199 <PhoneOff className="h-3.5 w-3.5" />
200 {t('chat.voice.rejoin')}
201 </button>
202 ) : null}
203
188204 <span className="voice-call-actions">
189205 <button
190206 type="button"
Modified web/src/features/chat/ui/voice/voice-room-panel.tsx web/src/features/chat/ui/voice/voice-room-panel.tsx +381 −712 View web/src/features/chat/ui/voice/voice-room-panel.tsx
@@ -1,62 +1,61 @@
11/**
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.
2 * The in-room voice surface.
3 *
4 * It is the orchestrator only: the windows come from `VoiceTileBoard`, the media
5 * controls from the vendored `ControlBar`, the device plumbing from
6 * `useVoiceTileLayout` + `VoiceDeviceSheet`. The connection itself belongs to
7 * `voiceSession`, so leaving this page can never hang up the call.
8 *
9 * Everything the old hand-rolled version had to guess about is read from the
10 * Room instead: who is speaking, whose mic is off, what is shared and by whom.
611 */
12import { useCallback, useEffect, useMemo, useState } from 'react'
713import {
8 useCallback,
9 useEffect,
10 useMemo,
11 useRef,
12 useState,
13 type PointerEvent as ReactPointerEvent,
14} from 'react'
15import {
1614 AlertCircle,
1715 Hand,
1816 Loader2,
19 LogOut,
20 Mic,
21 MicOff,
22 MonitorUp,
2317 MessageSquare,
2418 PhoneCall,
19 Settings2,
2520 ShieldCheck,
26 Video,
27 VideoOff,
21 Volume2,
2822} from 'lucide-react'
2923import {
3024 approveVoiceHand,
3125 cancelVoiceHand,
3226 denyVoiceHand,
33 getVoiceConfig,
34 getVoiceLayout,
35 listVoiceHands,
3627 kickVoiceParticipant,
28 listVoiceHands,
3729 moderateVoiceMute,
3830 raiseVoiceHand,
39 requestVoiceToken,
40 saveVoiceLayout,
4131 revokeVoiceHand,
4232 type VoiceHands,
43 type VoiceLayoutTiles,
4433 type VoiceParticipant,
4534} from '@/entities/chat'
46import { useVoiceSession, voiceSession, participantTracks } from '@/shared/voice'
35import {
36 ControlBar,
37 MediaDeviceSelect,
38 usePersistentUserChoices,
39} from '@/shared/livekit'
40import { useVoiceSession, VoiceProvider, voiceSession } from '@/shared/voice'
41import { joinVoiceRoom } from '../../model/voice-connection'
4742import { useT } from '@/shared/i18n'
4843import { useWsFrames } from '@/shared/ws'
49import { RoomEvent, type Track } from 'livekit-client'
5044import type { ChatMessage } from '@/entities/chat'
5145import { VoiceDanmaku } from './voice-danmaku'
5246import { VoiceDanmakuHistory } from './voice-danmaku-history'
47import { VoiceDeviceSheet, type VoiceJoinChoice } from './voice-device-sheet'
48import { VoiceTileBoard } from './voice-tiles'
49import { useVoiceTileLayout } from './voice-tile-layout'
50import { readOutputDevice, rememberOutputDevice } from './voice-devices'
5351
5452export interface VoiceRoomPanelProps {
5553 roomId: string
5654 roomName: string
5755 spaceName: string
5856 isStage: boolean
59 /** Server-published participants (webhook backed). */
57 /** Server-published participants (webhook backed): presence for people who are
58 * not on this page, plus the moderator-mute flags. */
6059 participants: VoiceParticipant[]
6160 /** Room transcript, reused as the bullet-comment feed. */
6261 danmakuMessages: ChatMessage[]
@@ -68,138 +67,6 @@
6867 canModerate: boolean
6968}
7069
71function TrackVideo({ track, muted }: { track: Track; muted: boolean }) {
72 const ref = useRef<HTMLVideoElement>(null)
73 useEffect(() => {
74 const element = ref.current
75 if (!element) return
76 track.attach(element)
77 return () => {
78 track.detach(element)
79 }
80 }, [track])
81 return (
82 <video
83 ref={ref}
84 autoPlay
85 playsInline
86 muted={muted}
87 className="h-full w-full rounded-xl bg-black object-contain"
88 />
89 )
90}
91
92/**
93 * A participant window parked on the canvas. Coordinates are normalized to the
94 * free span of the canvas (0..1) instead of pixels, so one shared placement
95 * stays inside the bounds for every member on every screen size.
96 */
97interface TilePlacement {
98 /** 0 -> glued to the left edge, 1 -> glued to the right edge. */
99 x: number
100 /** 0 -> glued to the top edge, 1 -> glued to the bottom edge. */
101 y: number
102 /** 0 -> smallest window, 1 -> largest window. */
103 w: number
104}
105
106interface TileBox {
107 left: number
108 top: number
109 width: number
110}
111
112interface TileGesture {
113 mode: 'move' | 'resize'
114 key: string
115 origin: TileBox
116 startX: number
117 startY: number
118}
119
120interface CanvasBox {
121 width: number
122 height: number
123}
124
125const LAYOUT_PREFIX = 'gitdata.voice.tiles.'
126const TILE_MIN_WIDTH = 140
127const TILE_MAX_WIDTH = 640
128const TILE_SNAP = 4
129const TILE_RATIO = 3 / 4
130/** Mirrors the padding of the tile canvas: windows never cross it. */
131const TILE_PAD_X = 16
132const TILE_PAD_Y = 18
133/** Shared-layout write throttle, so a drag stays live without flooding. */
134const LAYOUT_PUSH_MS = 140
135
136const clamp01 = (value: number) => (Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0)
137
138function spansOf(box: CanvasBox) {
139 const usableW = Math.max(0, box.width - TILE_PAD_X * 2)
140 const usableH = Math.max(0, box.height - TILE_PAD_Y * 2)
141 const maxW = Math.max(0, Math.min(TILE_MAX_WIDTH, usableW, usableH / TILE_RATIO))
142 return { usableW, usableH, maxW, minW: Math.min(TILE_MIN_WIDTH, maxW) }
143}
144
145/** Normalized placement -> pixels; clamping to the span makes overflow impossible. */
146function pixelsFor(box: CanvasBox, placement: TilePlacement): TileBox | null {
147 const { usableW, usableH, maxW, minW } = spansOf(box)
148 if (maxW <= 0) return null
149 const width = Math.round(minW + clamp01(placement.w) * (maxW - minW))
150 const height = width * TILE_RATIO
151 return {
152 left: Math.round(TILE_PAD_X + clamp01(placement.x) * Math.max(0, usableW - width)),
153 top: Math.round(TILE_PAD_Y + clamp01(placement.y) * Math.max(0, usableH - height)),
154 width,
155 }
156}
157
158function normalizedFor(box: CanvasBox, tile: TileBox): TilePlacement {
159 const { usableW, usableH, maxW, minW } = spansOf(box)
160 const width = Math.min(Math.max(minW, tile.width), maxW)
161 const height = width * TILE_RATIO
162 const spanW = Math.max(1, usableW - width)
163 const spanH = Math.max(1, usableH - height)
164 return {
165 x: clamp01((tile.left - TILE_PAD_X) / spanW),
166 y: clamp01((tile.top - TILE_PAD_Y) / spanH),
167 w: maxW <= minW ? 1 : clamp01((width - minW) / (maxW - minW)),
168 }
169}
170
171function readLayout(roomId: string): Record<string, TilePlacement> {
172 try {
173 const raw = localStorage.getItem(LAYOUT_PREFIX + roomId)
174 if (!raw) return {}
175 const parsed = JSON.parse(raw) as Record<string, Partial<TilePlacement>>
176 const out: Record<string, TilePlacement> = {}
177 for (const [key, value] of Object.entries(parsed)) {
178 if (
179 typeof value?.x === 'number' &&
180 typeof value?.y === 'number' &&
181 typeof value?.w === 'number'
182 ) {
183 out[key] = { x: clamp01(value.x), y: clamp01(value.y), w: clamp01(value.w) }
184 }
185 }
186 return out
187 } catch {
188 return {}
189 }
190}
191
192function writeLayout(roomId: string, layout: Record<string, TilePlacement>): void {
193 try {
194 const key = LAYOUT_PREFIX + roomId
195 if (Object.keys(layout).length === 0) localStorage.removeItem(key)
196 else localStorage.setItem(key, JSON.stringify(layout))
197 } catch {
198 // A layout that cannot persist is not worth failing the call over.
199 }
200}
201
202
20370const DANMAKU_KEY = 'gitdata.voice.danmaku'
20471
20572function readDanmakuPreference(): boolean {
@@ -218,10 +85,36 @@
21885 }
21986}
22087
221function initials(name: string): string {
222 const trimmed = name.trim()
223 if (!trimmed) return '?'
224 return [...trimmed].slice(0, 2).join('').toUpperCase()
88async function microphoneAlreadyGranted(): Promise<boolean> {
89 try {
90 const status = await navigator.permissions.query({ name: 'microphone' as PermissionName })
91 return status.state === 'granted'
92 } catch {
93 return false
94 }
95}
96
97/**
98 * Loudspeaker picker for the settings popover.
99 *
100 * Microphone and camera live in the official device menus attached to the
101 * control-bar toggles; the output route has no equivalent there. The select
102 * applies it through the room, which is what the audio elements owned by
103 * RoomAudioRenderer then follow.
104 */
105function VoiceOutputSelect() {
106 const { t } = useT()
107 return (
108 <div className="voice-settings">
109 <p className="voice-settings-caption">
110 <Volume2 className="h-3.5 w-3.5" aria-hidden /> {t('chat.voice.speaker')}
111 </p>
112 <MediaDeviceSelect
113 kind="audiooutput"
114 onActiveDeviceChange={deviceId => rememberOutputDevice(deviceId)}
115 />
116 </div>
117 )
225118}
226119
227120export function VoiceRoomPanel({
@@ -241,298 +134,111 @@
241134 const { t } = useT()
242135 const session = useVoiceSession()
243136 const joined = session.roomId === roomId && session.status !== 'idle'
137 /**
138 * A connection that gave up keeps the stored room (so the banner can offer a
139 * rejoin) but must fall back to the join card: an empty stage with nobody to
140 * hang up on reads as a broken page.
141 */
142 const inCall = joined && session.status !== 'failed'
143 const tileLayout = useVoiceTileLayout(roomId)
144 // The same store the control-bar device menus write to: one remembered set.
145 const { userChoices } = usePersistentUserChoices()
146 const [hands, setHands] = useState<VoiceHands | null>(null)
244147 const [joining, setJoining] = useState(false)
245148 const [error, setError] = useState<string | null>(null)
246 const [hands, setHands] = useState<VoiceHands | null>(null)
247 const [renderTick, setRenderTick] = useState(0)
149 const [sheetOpen, setSheetOpen] = useState(false)
150 const [settingsOpen, setSettingsOpen] = useState(false)
248151 const [danmakuOn, setDanmakuOn] = useState(readDanmakuPreference)
249152 const [historyOpen, setHistoryOpen] = useState(false)
250 const canvasRef = useRef<HTMLDivElement | null>(null)
251 const gestureRef = useRef<TileGesture | null>(null)
252 const pushRef = useRef({ last: 0, timer: 0 })
253 const tileIdsRef = useRef<Set<string>>(new Set())
254 const [canvasSize, setCanvasSize] = useState<CanvasBox>({ width: 0, height: 0 })
255 const [draggingKey, setDraggingKey] = useState<string | null>(null)
256 const [layout, setLayout] = useState<Record<string, TilePlacement>>(() => readLayout(roomId))
257 const layoutRef = useRef(layout)
258
259 // The measured canvas is what keeps parked windows inside it after a resize.
260 // It only exists once the member joined, so the observer is rebuilt with that.
261 useEffect(() => {
262 if (!joined) return
263 const el = canvasRef.current
264 if (!el) return
265 const measure = () =>
266 setCanvasSize(previous =>
267 previous.width === el.clientWidth && previous.height === el.clientHeight
268 ? previous
269 : { width: el.clientWidth, height: el.clientHeight },
270 )
271 measure()
272 const observer = new ResizeObserver(measure)
273 observer.observe(el)
274 return () => {
275 observer.disconnect()
276 setCanvasSize({ width: 0, height: 0 })
277 }
278 }, [joined])
279
280 const commitLayout = (next: Record<string, TilePlacement>) => {
281 layoutRef.current = next
282 setLayout(next)
283 }
284
285 const snap = (value: number) => Math.round(value / TILE_SNAP) * TILE_SNAP
286
287 /** Remote moves win immediately; the window being dragged stays under the hand. */
288 const mergeRemote = useCallback((tiles: VoiceLayoutTiles) => {
289 const active = gestureRef.current?.key
290 setLayout(previous => {
291 let changed = false
292 const next = { ...previous }
293 for (const [key, tile] of Object.entries(tiles)) {
294 if (key === active) continue
295 const current = previous[key]
296 if (!current || current.x !== tile.x || current.y !== tile.y || current.w !== tile.w) {
297 next[key] = { x: tile.x, y: tile.y, w: tile.w }
298 changed = true
299 }
300 }
301 return changed ? next : previous
302 })
303 }, [])
304
305 const pullLayout = useCallback(async () => {
306 try {
307 mergeRemote(await getVoiceLayout(roomId))
308 } catch {
309 // No shared layout yet simply means the automatic grid.
310 }
311 }, [roomId, mergeRemote])
312
313 /** Throttled during a drag, flushed when it ends: everyone else just follows. */
314 const pushLayout = useCallback(
315 (final: boolean) => {
316 const flush = () => {
317 pushRef.current.last = Date.now()
318 const here = tileIdsRef.current
319 if (here.size === 0) return
320 const tiles: VoiceLayoutTiles = {}
321 for (const key of here) {
322 const placement = layoutRef.current[key]
323 if (placement) tiles[key] = { x: placement.x, y: placement.y, w: placement.w }
324 }
325 void saveVoiceLayout(roomId, tiles).catch(() => undefined)
326 }
327 if (pushRef.current.timer) {
328 clearTimeout(pushRef.current.timer)
329 pushRef.current.timer = 0
330 }
331 const wait = LAYOUT_PUSH_MS - (Date.now() - pushRef.current.last)
332 if (final || wait <= 0) flush()
333 else
334 pushRef.current.timer = window.setTimeout(() => {
335 pushRef.current.timer = 0
336 flush()
337 }, wait)
338 },
339 [roomId],
340 )
341
342 useEffect(() => {
343 layoutRef.current = layout
344 writeLayout(roomId, layout)
345 }, [roomId, layout])
346
347 useEffect(() => {
348 const push = pushRef.current
349 // The local cache paints first; the shared layout lands right after it.
350 setLayout(readLayout(roomId))
351 return () => {
352 if (push.timer) clearTimeout(push.timer)
353 push.timer = 0
354 push.last = 0
355 }
356 }, [roomId])
357
358 const beginTileGesture = (
359 event: ReactPointerEvent<HTMLElement>,
360 key: string,
361 mode: 'move' | 'resize'
362 ) => {
363 const canvas = canvasRef.current
364 if (!canvas || event.button !== 0) return
365 // The moderation buttons stay clickable: grabbing a button is not grabbing
366 // the window, and pointer capture on the tile would swallow the click.
367 if (event.target instanceof Element && event.target.closest('button')) return
368 const el = canvas.querySelector<HTMLElement>('[data-tile="' + key + '"]')
369 if (!el) return
370 const canvasRect = canvas.getBoundingClientRect()
371 const tileRect = el.getBoundingClientRect()
372 const rectBox: TileBox = {
373 left: tileRect.left - canvasRect.left + canvas.scrollLeft,
374 top: tileRect.top - canvasRect.top + canvas.scrollTop,
375 width: tileRect.width,
376 }
377 const parked = layoutRef.current[key]
378 const origin = (parked ? pixelsFor(canvasSize, parked) : null) ?? rectBox
379 gestureRef.current = { mode, key, origin, startX: event.clientX, startY: event.clientY }
380 // A grid window lifts out of the flow at exactly where it was drawn, so
381 // the first pointer-down never makes it jump.
382 if (!parked) commitLayout({ ...layoutRef.current, [key]: normalizedFor(canvasSize, rectBox) })
383 setDraggingKey(key)
384 el.setPointerCapture(event.pointerId)
385 event.preventDefault()
386 }
387
388 const moveTileGesture = (event: ReactPointerEvent<HTMLElement>, key: string) => {
389 const active = gestureRef.current
390 if (!active || active.key !== key) return
391 const dx = event.clientX - active.startX
392 const dy = event.clientY - active.startY
393 const next: TileBox =
394 active.mode === 'move'
395 ? {
396 left: snap(active.origin.left + dx),
397 top: snap(active.origin.top + dy),
398 width: active.origin.width,
399 }
400 : { ...active.origin, width: snap(active.origin.width + dx) }
401 commitLayout({ ...layoutRef.current, [key]: normalizedFor(canvasSize, next) })
402 pushLayout(false)
403 }
404
405 const endTileGesture = (key: string) => {
406 const active = gestureRef.current
407 if (!active || active.key !== key) return
408 gestureRef.current = null
409 setDraggingKey(null)
410 pushLayout(true)
411 }
412
413 const releaseTile = (key: string) => {
414 const next = { ...layoutRef.current }
415 delete next[key]
416 commitLayout(next)
417 pushLayout(true)
418 }
419
420 const room = voiceSession.getRoom()
421 const trackByIdentity = useMemo(() => {
422 const map = new Map<string, Track>()
423 if (!room) return map
424 for (const participant of room.remoteParticipants.values()) {
425 const { camera, screen } = participantTracks(participant)
426 const track = screen ?? camera
427 if (track) map.set(participant.identity, track)
428 }
429 // The local camera is published into our own publications; without this
430 // the self tile would stay on the avatar while the camera is live for
431 // everyone else.
432 const local = participantTracks(room.localParticipant)
433 const localTrack = local.screen ?? local.camera
434 if (localTrack) map.set(room.localParticipant.identity, localTrack)
435 return map
436 // renderTick is the redraw signal for track attach/detach.
437 // eslint-disable-next-line react-hooks/exhaustive-deps
438 }, [room, renderTick])
439
440 const screenSharer = useMemo(() => {
441 if (!room) return null
442 for (const participant of room.remoteParticipants.values()) {
443 const { screen } = participantTracks(participant)
444 if (screen) return { identity: participant.identity, track: screen }
445 }
446 const { screen } = participantTracks(room.localParticipant)
447 if (screen) return { identity: room.localParticipant.identity, track: screen }
448 return null
449 // eslint-disable-next-line react-hooks/exhaustive-deps
450 }, [room, renderTick])
451
452 // The LiveKit room emits at its own cadence; fold it into one redraw counter
453 // so the tile memo above re-derives without subscribing to every event.
454 useEffect(() => {
455 if (!room) return
456 const bump = () => setRenderTick(value => value + 1)
457 room
458 .on(RoomEvent.LocalTrackPublished, bump)
459 .on(RoomEvent.LocalTrackUnpublished, bump)
460 .on(RoomEvent.TrackSubscribed, bump)
461 .on(RoomEvent.TrackUnsubscribed, bump)
462 .on(RoomEvent.ParticipantConnected, bump)
463 .on(RoomEvent.ParticipantDisconnected, bump)
464 .on(RoomEvent.ActiveSpeakersChanged, bump)
465 return () => {
466 room
467 .off(RoomEvent.LocalTrackPublished, bump)
468 .off(RoomEvent.LocalTrackUnpublished, bump)
469 .off(RoomEvent.TrackSubscribed, bump)
470 .off(RoomEvent.TrackUnsubscribed, bump)
471 .off(RoomEvent.ParticipantConnected, bump)
472 .off(RoomEvent.ParticipantDisconnected, bump)
473 .off(RoomEvent.ActiveSpeakersChanged, bump)
474 }
475 }, [room])
476153
477154 const refreshHands = useCallback(async () => {
478155 if (!isStage) return
479156 try {
480 setHands(await listVoiceHands(roomId))
157 const next = await listVoiceHands(roomId)
158 setHands(next)
159 voiceSession.markHandRaised(next.my_state === 'pending' || next.my_state === 'approved')
481160 } catch {
482161 // A failed hand list must not break the call surface.
483162 }
484163 }, [isStage, roomId])
485164
486 // The queue now decides whether the panel exists, so a raised hand has to
487 // open it immediately instead of on the next poll.
165 // The queue and the shared layout are both pushed over the chat socket, so no
166 // timer is needed: one event, one refetch.
488167 useWsFrames(frame => {
489168 if (!('room_id' in frame) || frame.room_id !== roomId) return
490 if (frame.type === 'event' && frame.event?.kind === 'voice_hands') void refreshHands()
491 if (frame.type === 'event' && frame.event?.kind === 'voice_layout') void pullLayout()
169 if (frame.type !== 'event' || !frame.event?.kind) return
170 if (frame.event.kind === 'voice_hands') void refreshHands()
171 if (frame.event.kind === 'voice_layout') void tileLayout.pullRemote()
492172 })
493173
494174 useEffect(() => {
495175 void refreshHands()
496 }, [refreshHands, session.status])
176 void tileLayout.pullRemote()
177 // The layout hook is stable per room; re-pull when the channel changes.
178 // eslint-disable-next-line react-hooks/exhaustive-deps
179 }, [refreshHands, roomId, session.status])
180
181 const mutedIds = useMemo(
182 () =>
183 new Set(
184 participants
185 .filter(participant => participant.moderator_muted)
186 .map(participant => String(participant.user_id)),
187 ),
188 [participants],
189 )
497190
498 useEffect(() => {
499 if (!isStage || !joined) return
500 const timer = setInterval(() => void refreshHands(), 15_000)
501 return () => clearInterval(timer)
502 }, [isStage, joined, refreshHands])
191 const start = useCallback(
192 async (choice: VoiceJoinChoice) => {
193 setJoining(true)
194 setError(null)
195 try {
196 await joinVoiceRoom({
197 roomId,
198 roomLabel: roomName,
199 spaceName,
200 ...choice,
201 })
202 await voiceSession.resumeAudio()
203 await refreshHands()
204 setSheetOpen(false)
205 } catch (cause) {
206 setError(cause instanceof Error ? cause.message : t('chat.voice.joinFailed'))
207 setSheetOpen(false)
208 } finally {
209 setJoining(false)
210 }
211 },
212 [roomId, roomName, spaceName, refreshHands, t],
213 )
503214
504 const join = useCallback(async () => {
505 setJoining(true)
506 setError(null)
507 try {
508 const config = await getVoiceConfig()
509 if (!config.enabled) throw new Error(t('chat.voice.disabled'))
510 const token = await requestVoiceToken(roomId)
511 await voiceSession.join({
512 roomId,
513 roomLabel: roomName,
514 spaceName,
515 url: token.url,
516 token: token.token,
517 canPublish: token.can_publish,
518 isStage: token.is_stage,
519 startWithMic: token.can_publish && !token.is_stage,
520 })
521 voiceSession.markHandRaised(token.hand_state === 'pending' || token.hand_state === 'approved')
522 await voiceSession.resumeAudio()
523 await refreshHands()
524 } catch (cause) {
525 setError(cause instanceof Error ? cause.message : t('chat.voice.joinFailed'))
526 } finally {
527 setJoining(false)
215 /**
216 * Fast path: a member who has joined before and already granted the
217 * microphone goes straight in. Otherwise the sheet runs first - it is the one
218 * place the browser is asked for permission, and its preview is what makes
219 * the browser reveal its local media candidates before the connect.
220 */
221 const onJoinClick = useCallback(async () => {
222 const asRemembered = (value: string | undefined) =>
223 !value || value === 'default' ? null : value
224 // First call in this browser, a Stage channel, or a browser that has never
225 // seen the microphone: the sheet runs first, so permission and the local
226 // media candidates are settled before the connect.
227 if (isStage || !userChoices.audioDeviceId || !(await microphoneAlreadyGranted())) {
228 setSheetOpen(true)
229 return
528230 }
529 }, [roomId, roomName, spaceName, refreshHands, t])
530
231 await start({
232 startWithMic: true,
233 audioDeviceId: asRemembered(userChoices.audioDeviceId),
234 videoDeviceId: asRemembered(userChoices.videoDeviceId),
235 audioOutputDeviceId: readOutputDevice(),
236 })
237 }, [isStage, start, userChoices.audioDeviceId, userChoices.videoDeviceId])
531238 const onRaiseHand = useCallback(async () => {
532239 setError(null)
533240 try {
534241 await raiseVoiceHand(roomId)
535 voiceSession.markHandRaised(true)
536242 await refreshHands()
537243 } catch (cause) {
538244 setError(cause instanceof Error ? cause.message : t('chat.voice.handFailed'))
@@ -543,7 +249,6 @@
543249 setError(null)
544250 try {
545251 await cancelVoiceHand(roomId)
546 voiceSession.markHandRaised(false)
547252 await refreshHands()
548253 } catch (cause) {
549254 setError(cause instanceof Error ? cause.message : t('chat.voice.handCancelFailed'))
@@ -565,23 +270,27 @@
565270 [roomId, refreshHands, t],
566271 )
567272
568 const tiles = useMemo(
569 () =>
570 participants.map(participant => ({
571 ...participant,
572 isSelf: participant.user_id === viewerId,
573 videoTrack: trackByIdentity.get(participant.user_id),
574 })),
575 [participants, trackByIdentity, viewerId],
273 const onMute = useCallback(
274 (userId: string, muted: boolean) => {
275 setError(null)
276 void moderateVoiceMute(roomId, userId, muted)
277 .then(() => refreshHands())
278 .catch(cause =>
279 setError(cause instanceof Error ? cause.message : t('chat.voice.handDecideFailed')),
280 )
281 },
282 [roomId, refreshHands, t],
576283 )
577284
578 // A member arriving with a parked window of their own has to learn about it,
579 // and only windows for members who are here are ever written back.
580 const tileIdKey = useMemo(() => tiles.map(tile => tile.user_id).join(','), [tiles])
581 useEffect(() => {
582 tileIdsRef.current = tileIdKey ? new Set(tileIdKey.split(',')) : new Set()
583 if (!gestureRef.current) void pullLayout()
584 }, [tileIdKey, pullLayout])
285 const onKick = useCallback(
286 (userId: string) => {
287 setError(null)
288 void kickVoiceParticipant(roomId, userId).catch(cause =>
289 setError(cause instanceof Error ? cause.message : t('chat.voice.handDecideFailed')),
290 )
291 },
292 [roomId, t],
293 )
585294
586295 const myHand = hands?.my_state
587296 const pendingHands = (hands?.hands ?? []).filter(hand => hand.state === 'pending')
@@ -591,14 +300,41 @@
591300 const showHands =
592301 isStage && (!canModerate || pendingHands.length > 0 || approvedHands.length > 0)
593302
303 // Keyboard: M / V toggle the local mic and camera while focus is not inside a
304 // control, so a member can mute without hunting for the button.
305 useEffect(() => {
306 if (!inCall) return
307 const onKey = (event: KeyboardEvent) => {
308 if (event.metaKey || event.ctrlKey || event.altKey) return
309 const target = event.target
310 if (target instanceof HTMLElement && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
311 if (event.key === 'm' || event.key === 'M') void voiceSession.setMicrophone(!session.micEnabled)
312 if (event.key === 'v' || event.key === 'V') void voiceSession.setCamera(!session.cameraEnabled)
313 if (event.key === 'Escape') {
314 setSheetOpen(false)
315 setSettingsOpen(false)
316 }
317 }
318 window.addEventListener('keydown', onKey)
319 return () => window.removeEventListener('keydown', onKey)
320 }, [inCall, session.micEnabled, session.cameraEnabled])
321
594322 return (
595 <div className="voice-room flex min-h-0 flex-1 flex-col">
323 <div
324 className="voice-room flex min-h-0 flex-1 flex-col"
325 data-lk-theme="default"
326 >
596327 {session.notice || error ? (
597328 <div className="voice-banners">
598329 {session.notice ? (
599330 <div className="voice-banner" role="status">
600331 <AlertCircle className="h-3.5 w-3.5 shrink-0" />
601332 <span className="min-w-0 truncate">{session.notice}</span>
333 {session.needsManualRejoin ? (
334 <button type="button" onClick={() => void voiceSession.rejoin()}>
335 {t('chat.voice.rejoin')}
336 </button>
337 ) : null}
602338 <button type="button" onClick={() => voiceSession.clearNotice()}>
603339 {t('common.action.ignore')}
604340 </button>
@@ -613,6 +349,12 @@
613349 </button>
614350 </div>
615351 ) : null}
352 {session.noticeDetail ? (
353 <details className="voice-debug">
354 <summary>{t('chat.voice.debugDetails')}</summary>
355 <code>{session.noticeDetail}</code>
356 </details>
357 ) : null}
616358 </div>
617359 ) : null}
618360 <div className="voice-call-body">
@@ -620,284 +362,211 @@
620362 <VoiceDanmaku messages={danmakuMessages} seeded={danmakuSeeded} nameOf={nameOf} />
621363 ) : null}
622364
623 {screenSharer ? (
624 <div className="voice-stage">
625 <TrackVideo track={screenSharer.track} muted={screenSharer.identity === viewerId} />
626 <div className="voice-stage-caption">
627 <MonitorUp className="h-3.5 w-3.5" />
628 {t('chat.voice.sharing', { name: voiceSession.nameOf(screenSharer.identity) })}
629 </div>
630 </div>
631 ) : null}
632
633 {!joined ? (
634 <div className="voice-join">
635 <div className="voice-join-icon">
636 <PhoneCall className="h-6 w-6" />
637 </div>
638 <p className="voice-join-title">{roomName}</p>
639 <p className="voice-join-hint">
640 {isStage ? t('chat.voice.stageHint') : t('chat.voice.joinHint')}
641 </p>
642 {isStage && myHand === 'pending' ? (
643 <div className="flex flex-col items-center gap-2">
644 <span className="voice-hand-pending">
645 <Hand className="h-3.5 w-3.5" /> {t('chat.voice.handPending')}
646 </span>
647 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
648 {t('chat.voice.cancelHand')}
649 </button>
365 {!inCall ? (
366 <div className="voice-join">
367 <div className="voice-join-icon">
368 <PhoneCall className="h-6 w-6" />
650369 </div>
651 ) : null}
652 <button
653 type="button"
654 className="voice-primary-button"
655 disabled={joining || session.status === 'connecting'}
656 onClick={() => void join()}
657 >
658 {joining || session.status === 'connecting' ? (
659 <>
660 <Loader2 className="h-4 w-4 animate-spin" /> {t('chat.voice.joining')}
661 </>
662 ) : (
663 t('chat.voice.join')
664 )}
665 </button>
666 </div>
667 ) : (
668 <>
669 <div ref={canvasRef} className="voice-tiles" role="list">
670 {tiles.map(tile => {
671 const speaking = (tile.isSelf && session.speaking) || tile.speaking
672 const parked = layout[tile.user_id]
673 const placed = parked ? pixelsFor(canvasSize, parked) : null
674 return (
675 <div
676 key={tile.user_id}
677 data-tile={tile.user_id}
678 role="listitem"
679 className={[
680 'voice-tile',
681 speaking ? 'is-speaking' : '',
682 placed ? 'is-floating' : '',
683 draggingKey === tile.user_id ? 'is-dragging' : '',
684 ]
685 .filter(Boolean)
686 .join(' ')}
687 style={
688 placed
689 ? { left: placed.left, top: placed.top, width: placed.width }
690 : undefined
691 }
692 title={placed ? t('chat.voice.tilePlaced') : t('chat.voice.tileHint')}
693 onPointerDown={event => beginTileGesture(event, tile.user_id, 'move')}
694 onPointerMove={event => moveTileGesture(event, tile.user_id)}
695 onPointerUp={() => endTileGesture(tile.user_id)}
696 onPointerCancel={() => endTileGesture(tile.user_id)}
697 onDoubleClick={event => {
698 if (event.target instanceof Element && event.target.closest('button')) return
699 releaseTile(tile.user_id)
700 }}
701 >
702 {tile.videoTrack ? (
703 <TrackVideo track={tile.videoTrack} muted={tile.isSelf} />
704 ) : (
705 <div className="voice-tile-avatar">
706 {tile.avatar_url ? (
707 <img src={tile.avatar_url} alt="" />
708 ) : (
709 <span>{initials(tile.display_name)}</span>
710 )}
711 </div>
712 )}
713 <div className="voice-tile-meta">
714 <span className="truncate">{tile.display_name}</span>
715 {tile.moderator_muted || !tile.can_publish ? (
716 <MicOff className="voice-tile-flag h-3 w-3 shrink-0" />
717 ) : null}
718 {canModerate && !tile.isSelf ? (
719 <div className="voice-tile-actions">
720 <button
721 type="button"
722 title={tile.moderator_muted ? t('chat.voice.forceUnmute') : t('chat.voice.forceMute')}
723 onClick={() =>
724 void moderateVoiceMute(roomId, tile.user_id, !tile.moderator_muted)
725 }
726 >
727 {tile.moderator_muted ? (
728 <Mic className="h-3.5 w-3.5" />
729 ) : (
730 <MicOff className="h-3.5 w-3.5" />
731 )}
732 </button>
733 <button
734 type="button"
735 title={t('chat.voice.kick')}
736 onClick={() => void kickVoiceParticipant(roomId, tile.user_id)}
737 >
738 <LogOut className="h-3.5 w-3.5" />
739 </button>
740 </div>
741 ) : null}
742 </div>
743 <span
744 className="voice-tile-resize"
745 aria-hidden
746 onPointerDown={event => {
747 event.stopPropagation()
748 beginTileGesture(event, tile.user_id, 'resize')
749 }}
750 />
751 </div>
752 )
753 })}
754 {tiles.length === 0 ? (
755 <p className="voice-empty">{t('chat.voice.solo')}</p>
370 <p className="voice-join-title">{roomName}</p>
371 <p className="voice-join-hint">
372 {isStage ? t('chat.voice.stageHint') : t('chat.voice.joinHint')}
373 </p>
374 {participants.length > 0 ? (
375 <p className="voice-join-members">
376 {t('chat.voice.membersCount', { count: participants.length })}
377 </p>
756378 ) : null}
757 </div>
758
759 {showHands ? (
760 <div className="voice-hands">
761 <div className="voice-hands-head">
762 <Hand className="h-4 w-4" />
763 <span>{t('chat.voice.hands')}</span>
764 {!canModerate ? (
765 myHand === 'approved' ? (
766 <span className="voice-badge ok">
767 <ShieldCheck className="h-3 w-3" /> {t('chat.voice.canSpeak')}
768 </span>
769 ) : session.hasRaisedHand || myHand === 'pending' ? (
770 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
771 {t('chat.voice.cancelHand')}
772 </button>
773 ) : (
774 <button type="button" className="voice-ghost-button" onClick={onRaiseHand}>
775 {t('chat.voice.raiseHand')}
776 </button>
777 )
778 ) : (
779 <span className="voice-badge">
780 {t('chat.voice.pendingCount', { count: pendingHands.length })}
781 </span>
782 )}
379 {isStage && myHand === 'pending' ? (
380 <div className="flex flex-col items-center gap-2">
381 <span className="voice-hand-pending">
382 <Hand className="h-3.5 w-3.5" /> {t('chat.voice.handPending')}
383 </span>
384 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
385 {t('chat.voice.cancelHand')}
386 </button>
783387 </div>
784 {canModerate ? (
785 pendingHands.length === 0 ? (
786 <p className="voice-hands-empty">{t('chat.voice.noHands')}</p>
787 ) : (
788 <ul className="voice-hands-list">
789 {pendingHands.map(hand => (
790 <li key={hand.user_id}>
791 <span className="truncate">{hand.display_name}</span>
792 <span className="voice-hands-actions">
793 <button
794 type="button"
795 className="voice-approve"
796 onClick={() => void decide(hand.user_id, 'approve')}
797 >
798 {t('common.action.approve')}
799 </button>
800 <button
801 type="button"
802 className="voice-deny"
803 onClick={() => void decide(hand.user_id, 'deny')}
804 >
805 {t('common.action.reject')}
806 </button>
807 </span>
808 </li>
809 ))}
810 </ul>
811 )
812 ) : null}
813 {canModerate && approvedHands.length > 0 ? (
814 <ul className="voice-hands-list">
815 {approvedHands.map(hand => (
816 <li key={hand.user_id}>
817 <span className="truncate">
818 {t('chat.voice.speaking', { name: hand.display_name })}
819 </span>
820 <span className="voice-hands-actions">
821 <button
822 type="button"
823 className="voice-deny"
824 onClick={() => void decide(hand.user_id, 'revoke')}
825 >
826 {t('chat.voice.revoke')}
827 </button>
828 </span>
829 </li>
830 ))}
831 </ul>
832 ) : null}
833 </div>
834 ) : null}
835
836 {historyOpen ? (
837 <VoiceDanmakuHistory
838 messages={danmakuMessages}
839 nameOf={nameOf}
840 hasOlder={danmakuHasOlder}
841 onLoadOlder={onLoadOlderDanmaku}
842 flying={danmakuOn}
843 onFlyingChange={next => {
844 setDanmakuOn(next)
845 toggleDanmakuPreference(next)
846 }}
847 onClose={() => setHistoryOpen(false)}
848 />
849 ) : null}
850
851 <div className="voice-controls">
852 <button
853 type="button"
854 className={['voice-control', session.micEnabled ? '' : 'is-off'].join(' ')}
855 disabled={!session.canPublish}
856 title={session.canPublish ? t('chat.voice.mic') : t('chat.voice.noPublish')}
857 onClick={() => void voiceSession.setMicrophone(!session.micEnabled)}
858 >
859 {session.micEnabled ? <Mic className="h-4 w-4" /> : <MicOff className="h-4 w-4" />}
860 </button>
861 <button
862 type="button"
863 className={['voice-control', session.cameraEnabled ? '' : 'is-off'].join(' ')}
864 disabled={!session.canPublish}
865 title={t('chat.voice.camera')}
866 onClick={() => void voiceSession.setCamera(!session.cameraEnabled)}
867 >
868 {session.cameraEnabled ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
869 </button>
870 <button
871 type="button"
872 className={['voice-control', session.screenShareEnabled ? '' : 'is-off'].join(' ')}
873 disabled={!session.canPublish}
874 title={t('chat.voice.screenShare')}
875 onClick={() => void voiceSession.setScreenShare(!session.screenShareEnabled)}
876 >
877 <MonitorUp className="h-4 w-4" />
878 </button>
388 ) : null}
879389 <button
880390 type="button"
881 data-danmaku-toggle="true"
882 className={['voice-control', historyOpen ? 'is-active' : ''].join(' ')}
883 title={t('chat.danmaku.title')}
884 aria-expanded={historyOpen}
885 onClick={() => setHistoryOpen(value => !value)}
391 className="voice-primary-button"
392 disabled={joining || session.status === 'connecting'}
393 onClick={() => void onJoinClick()}
886394 >
887 <MessageSquare className="h-4 w-4" />
395 {joining || session.status === 'connecting' ? (
396 <>
397 <Loader2 className="h-4 w-4 animate-spin" /> {t('chat.voice.joining')}
398 </>
399 ) : session.status === 'failed' ? (
400 t('chat.voice.rejoin')
401 ) : (
402 t('chat.voice.join')
403 )}
888404 </button>
889 <span className="voice-control-sep" aria-hidden />
890405 <button
891406 type="button"
892 className="voice-control danger"
893 title={t('chat.voice.hangUp')}
894 onClick={() => void voiceSession.leave()}
407 className="voice-ghost-button"
408 disabled={joining}
409 onClick={() => setSheetOpen(true)}
895410 >
896 <LogOut className="h-4 w-4" />
411 {t('chat.voice.deviceTitle')}
897412 </button>
898413 </div>
899 </>
900 )}
414 ) : (
415 <VoiceProvider>
416 <div className="voice-call-stack">
417 <VoiceTileBoard
418 roomId={roomId}
419 viewerId={viewerId}
420 canModerate={canModerate}
421 layout={tileLayout}
422 mutedIds={mutedIds}
423 onMute={onMute}
424 onKick={onKick}
425 />
426
427 {showHands ? (
428 <div className="voice-hands">
429 <div className="voice-hands-head">
430 <Hand className="h-4 w-4" />
431 <span>{t('chat.voice.hands')}</span>
432 {!canModerate ? (
433 myHand === 'approved' ? (
434 <span className="voice-badge ok">
435 <ShieldCheck className="h-3 w-3" /> {t('chat.voice.canSpeak')}
436 </span>
437 ) : session.hasRaisedHand || myHand === 'pending' ? (
438 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
439 {t('chat.voice.cancelHand')}
440 </button>
441 ) : (
442 <button type="button" className="voice-ghost-button" onClick={onRaiseHand}>
443 {t('chat.voice.raiseHand')}
444 </button>
445 )
446 ) : (
447 <span className="voice-badge">
448 {t('chat.voice.pendingCount', { count: pendingHands.length })}
449 </span>
450 )}
451 </div>
452 {canModerate ? (
453 pendingHands.length === 0 ? (
454 <p className="voice-hands-empty">{t('chat.voice.noHands')}</p>
455 ) : (
456 <ul className="voice-hands-list">
457 {pendingHands.map(hand => (
458 <li key={hand.user_id}>
459 <span className="truncate">{hand.display_name}</span>
460 <span className="voice-hands-actions">
461 <button
462 type="button"
463 className="voice-approve"
464 onClick={() => void decide(hand.user_id, 'approve')}
465 >
466 {t('common.action.approve')}
467 </button>
468 <button
469 type="button"
470 className="voice-deny"
471 onClick={() => void decide(hand.user_id, 'deny')}
472 >
473 {t('common.action.reject')}
474 </button>
475 </span>
476 </li>
477 ))}
478 </ul>
479 )
480 ) : null}
481 {canModerate && approvedHands.length > 0 ? (
482 <ul className="voice-hands-list">
483 {approvedHands.map(hand => (
484 <li key={hand.user_id}>
485 <span className="truncate">
486 {t('chat.voice.speaking', { name: hand.display_name })}
487 </span>
488 <span className="voice-hands-actions">
489 <button
490 type="button"
491 className="voice-deny"
492 onClick={() => void decide(hand.user_id, 'revoke')}
493 >
494 {t('chat.voice.revoke')}
495 </button>
496 </span>
497 </li>
498 ))}
499 </ul>
500 ) : null}
501 </div>
502 ) : null}
503
504 {historyOpen ? (
505 <VoiceDanmakuHistory
506 messages={danmakuMessages}
507 nameOf={nameOf}
508 hasOlder={danmakuHasOlder}
509 onLoadOlder={onLoadOlderDanmaku}
510 flying={danmakuOn}
511 onFlyingChange={next => {
512 setDanmakuOn(next)
513 toggleDanmakuPreference(next)
514 }}
515 onClose={() => setHistoryOpen(false)}
516 />
517 ) : null}
518
519 {settingsOpen ? (
520 <div className="voice-settings-anchor">
521 <VoiceOutputSelect />
522 </div>
523 ) : null}
524
525 <ControlBar
526 className="voice-control-bar"
527 saveUserChoices={false}
528 controls={{
529 microphone: true,
530 camera: true,
531 screenShare: true,
532 chat: false,
533 settings: false,
534 leave: true,
535 }}
536 />
537 <div className="voice-controls">
538 <button
539 type="button"
540 className={['voice-control', settingsOpen ? 'is-active' : ''].join(' ')}
541 title={t('livekit.settings')}
542 aria-expanded={settingsOpen}
543 onClick={() => setSettingsOpen(value => !value)}
544 >
545 <Settings2 className="h-4 w-4" />
546 </button>
547 <button
548 type="button"
549 data-danmaku-toggle="true"
550 className={['voice-control', historyOpen ? 'is-active' : ''].join(' ')}
551 title={t('chat.danmaku.title')}
552 aria-expanded={historyOpen}
553 onClick={() => setHistoryOpen(value => !value)}
554 >
555 <MessageSquare className="h-4 w-4" />
556 </button>
557 </div>
558 </div>
559 </VoiceProvider>
560 )}
561
562 <VoiceDeviceSheet
563 open={sheetOpen}
564 busy={joining}
565 allowMicrophone={!isStage}
566 error={error}
567 onCancel={() => setSheetOpen(false)}
568 onConfirm={choice => void start(choice)}
569 />
901570 </div>
902571 </div>
903572 )
Modified web/src/features/chat/ui/voice/voice.css web/src/features/chat/ui/voice/voice.css +233 −148 View web/src/features/chat/ui/voice/voice.css
@@ -34,33 +34,6 @@
3434}
3535.voice-banner button:hover { opacity: 1; background: color-mix(in srgb, var(--destructive) 12%, transparent); }
3636
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
6437/* 未加入时的引导 */
6538.voice-join {
6639 display: flex;
@@ -122,125 +95,6 @@
12295 font-size: 12px;
12396}
12497
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 /* Parked windows are absolutely placed against this box. */
137 position: relative;
138 overflow-x: hidden;
139}
140.voice-tile {
141 position: relative;
142 display: flex;
143 aspect-ratio: 4 / 3;
144 flex-direction: column;
145 overflow: hidden;
146 border: 1px solid var(--shell-hairline);
147 border-radius: 16px;
148 background: var(--shell-field);
149 box-shadow: var(--shell-shadow);
150 transition: border-color 160ms, box-shadow 160ms;
151}
152.voice-tile.is-speaking {
153 border-color: color-mix(in srgb, var(--primary) 70%, transparent);
154 box-shadow:
155 0 0 0 2px color-mix(in srgb, var(--primary) 26%, transparent),
156 var(--shell-shadow);
157}
158.voice-tile-avatar { display: grid; flex: 1; place-items: center; font-size: 15px; font-weight: 600; }
159.voice-tile-avatar span {
160 display: grid;
161 width: 54px;
162 height: 54px;
163 place-items: center;
164 border-radius: 50%;
165 background: var(--shell-tile);
166 color: var(--shell-ink);
167}
168.voice-tile-avatar img { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; }
169.voice-tile-meta {
170 position: absolute;
171 inset: auto 0 0 0;
172 display: flex;
173 align-items: center;
174 gap: 6px;
175 padding: 16px 9px 8px;
176 background: linear-gradient(to top, rgb(10 12 13 / 66%), transparent);
177 color: #fff;
178 font-size: 12px;
179 font-weight: 500;
180}
181.voice-tile-flag { flex: none; color: #ff9c86; }
182.voice-tile-actions { display: flex; margin-left: auto; gap: 2px; opacity: 0; transition: opacity 160ms; }
183.voice-tile:hover .voice-tile-actions,
184.voice-tile:focus-within .voice-tile-actions { opacity: 1; }
185.voice-tile-actions button {
186 display: grid;
187 width: 24px;
188 height: 24px;
189 place-items: center;
190 border-radius: 8px;
191 color: #fff;
192 background: rgb(255 255 255 / 16%);
193}
194.voice-tile-actions button:hover { background: rgb(255 255 255 / 32%); }
195.voice-empty {
196 grid-column: 1 / -1;
197 padding: 40px 8px;
198 color: var(--shell-ink-muted);
199 font-size: 12.5px;
200 text-align: center;
201}
202
203/* 参与者可摆位:拖动/缩放后离开网格,成为可复位的浮窗 */
204.voice-tiles { position: relative; }
205.voice-tile { user-select: none; touch-action: none; cursor: grab; }
206.voice-tile:active { cursor: grabbing; }
207.voice-tile-actions button { cursor: pointer; }
208.voice-tile.is-floating {
209 position: absolute;
210 z-index: 1;
211 box-shadow: 0 12px 30px rgb(15 17 19 / 18%);
212}
213/* The window under the pointer wins over every parked neighbour. */
214.voice-tile.is-dragging { z-index: 3; cursor: grabbing; }
215.voice-tile.is-speaking, .voice-tile.is-floating.is-speaking {
216 box-shadow:
217 0 0 0 2px color-mix(in srgb, var(--primary) 26%, transparent),
218 0 12px 30px rgb(15 17 19 / 18%);
219}
220.voice-tile-resize {
221 position: absolute;
222 right: 2px;
223 bottom: 2px;
224 z-index: 1;
225 width: 18px;
226 height: 18px;
227 border-radius: 6px;
228 cursor: nwse-resize;
229 opacity: 0;
230 background: linear-gradient(
231 135deg,
232 transparent 40%,
233 rgb(255 255 255 / 78%) 40%,
234 rgb(255 255 255 / 78%) 55%,
235 transparent 55%,
236 transparent 68%,
237 rgb(255 255 255 / 78%) 68%,
238 rgb(255 255 255 / 78%) 83%,
239 transparent 83%
240 );
241}
242.voice-tile:hover .voice-tile-resize { opacity: .85; }
243
24498/* 舞台发言申请 */
24599.voice-hands {
246100 flex-shrink: 0;
@@ -288,12 +142,15 @@
288142
289143/* 控制条:居中的悬浮胶囊 */
290144.voice-controls {
145 position: absolute;
146 right: 16px;
147 bottom: 70px;
148 z-index: 6;
291149 display: flex;
292150 flex-shrink: 0;
293 align-self: center;
294151 align-items: center;
295152 gap: 4px;
296 margin: 0 auto 16px;
153 margin: 0;
297154 padding: 6px;
298155 border: 1px solid var(--shell-hairline);
299156 border-radius: 999px;
@@ -576,3 +433,231 @@
576433 animation: none;
577434 }
578435}
436
437/* ---------------------------------------------- LiveKit 组件主题对齐 shell --
438 * vendored 的 @livekit/components-* 用自带的编译样式(在 app/index.tsx 引入),
439 * 并以 [data-lk-theme] 为作用域。下面只重映射设计令牌,让官方布局看起来是本
440 * 应用的一部分;结构性覆盖一律按类名写,不用 !important。
441 */
442.voice-room[data-lk-theme],
443.voice-host[data-lk-theme] {
444 color-scheme: light dark;
445 --lk-bg: var(--shell-surface);
446 --lk-bg2: var(--shell-tile);
447 --lk-bg3: var(--shell-active);
448 --lk-bg4: var(--shell-active);
449 --lk-bg5: var(--shell-focus);
450 --lk-fg: var(--shell-ink);
451 --lk-fg2: var(--shell-ink);
452 --lk-fg3: var(--shell-ink-muted);
453 --lk-fg4: var(--shell-ink-muted);
454 --lk-fg5: var(--shell-ink-muted);
455 --lk-border-color: var(--shell-hairline);
456 --lk-accent-fg: var(--primary-foreground);
457 --lk-accent-bg: var(--primary);
458 --lk-danger: var(--destructive);
459 --lk-danger2: var(--error-foreground);
460 --lk-success-fg: var(--success-foreground);
461 --lk-success: var(--success-foreground);
462 --lk-control-fg: var(--shell-ink);
463 --lk-control-bg: var(--shell-field);
464 --lk-control-hover-bg: var(--shell-active);
465 --lk-font-family: inherit;
466 --lk-font-size: 13px;
467 --lk-border-radius: 12px;
468 --lk-grid-gap: 10px;
469 --lk-control-bar-height: 58px;
470 --lk-box-shadow: var(--shell-shadow);
471}
472
473/* 通话栈:官方布局铺满剩余高度,控制条与话轮面板浮在其上。 */
474.voice-call-stack { position: relative; display: flex; min-height: 0; flex: 1; flex-direction: column; }
475.voice-board { position: relative; flex: 1; min-height: 0; padding: 6px 14px; overflow: hidden; }
476.voice-board .lk-grid-layout,
477.voice-board .lk-focus-layout { width: 100%; height: 100%; }
478.voice-board .lk-carousel { background: transparent; }
479.voice-board .lk-participant-tile { border: 1px solid var(--shell-hairline); }
480.voice-board .lk-participant-tile[data-lk-speaking='true'] {
481 border-color: color-mix(in srgb, var(--success-foreground) 55%, transparent);
482 box-shadow: 0 0 0 1px color-mix(in srgb, var(--success-foreground) 30%, transparent);
483}
484.voice-board .lk-video-track,
485.voice-board .lk-audio-bar-visualizer { border-radius: 11px; }
486
487/* 参与者窗口:网格里的一个,或被摆位后浮出来的一个。 */
488.voice-window { position: relative; min-height: 0; user-select: none; touch-action: none; }
489.voice-window .lk-participant-tile { height: 100%; }
490.voice-window.is-floating {
491 position: absolute;
492 z-index: 2;
493 aspect-ratio: 3 / 4;
494 cursor: grab;
495 pointer-events: auto;
496}
497.voice-window.is-dragging { z-index: 3; cursor: grabbing; }
498/* 摄像头窗口上的麦克风状态:官方指示器只标注它渲染的那一路。 */
499.voice-call-quality {
500 width: 14px;
501 height: 14px;
502 margin-left: 6px;
503}
504.voice-call-quality .lk-connection-quality {
505 width: 100%;
506 height: 100%;
507}
508
509.voice-window-mic {
510 position: absolute;
511 bottom: 9px;
512 left: 30px;
513 z-index: 1;
514 color: #ff9c86;
515}
516
517.voice-window-resize {
518 position: absolute;
519 right: 4px;
520 bottom: 4px;
521 width: 16px;
522 height: 16px;
523 border-right: 2px solid var(--shell-ink-muted);
524 border-bottom: 2px solid var(--shell-ink-muted);
525 border-radius: 0 0 6px 0;
526 opacity: 0;
527 cursor: nwse-resize;
528 transition: opacity 160ms;
529}
530.voice-window:hover .voice-window-resize { opacity: .85; }
531
532/* 悬停菜单:聚焦 / 该成员音量 / 主持人操作。 */
533.voice-tile-menu {
534 position: absolute;
535 top: 8px;
536 right: 8px;
537 display: flex;
538 align-items: center;
539 gap: 4px;
540 padding: 4px;
541 border-radius: 10px;
542 background: color-mix(in srgb, var(--shell-surface) 82%, transparent);
543 box-shadow: var(--shell-shadow);
544 opacity: 0;
545 transition: opacity 160ms;
546 pointer-events: auto;
547}
548.voice-window:hover .voice-tile-menu,
549.voice-window:focus-within .voice-tile-menu { opacity: 1; }
550.voice-tile-menu button {
551 display: grid;
552 place-items: center;
553 width: 26px;
554 height: 26px;
555 border: 0;
556 border-radius: 7px;
557 background: transparent;
558 color: var(--shell-ink);
559 cursor: pointer;
560}
561.voice-tile-menu button:hover { background: var(--shell-active); }
562.voice-tile-menu button.is-active { color: var(--primary); }
563.voice-tile-volume { display: flex; align-items: center; gap: 4px; color: var(--shell-ink-muted); }
564.voice-tile-volume input[type='range'] { width: 68px; accent-color: var(--primary); cursor: pointer; }
565
566/* 会中设备弹层。 */
567.voice-settings-anchor {
568 position: absolute;
569 right: 16px;
570 bottom: 78px;
571 z-index: 5;
572}
573.voice-settings {
574 display: flex;
575 flex-direction: column;
576 gap: 6px;
577 min-width: 244px;
578 padding: 12px;
579 border: 1px solid var(--shell-hairline);
580 border-radius: 14px;
581 background: var(--shell-surface);
582 box-shadow: var(--shell-shadow);
583}
584.voice-settings-caption { color: var(--shell-ink-muted); font-size: 11.5px; font-weight: 600; }
585.voice-settings .lk-input-form-control,
586.voice-settings select {
587 width: 100%;
588 padding: 6px 8px;
589 border: 1px solid var(--shell-hairline);
590 border-radius: 9px;
591 background: var(--shell-field);
592 color: var(--shell-ink);
593 font-size: 12.5px;
594}
595
596/* 加入前的设备与预览层。 */
597.voice-sheet-backdrop {
598 position: fixed;
599 inset: 0;
600 z-index: 60;
601 display: grid;
602 place-items: center;
603 padding: 24px;
604 background: rgb(17 17 17 / 42%);
605}
606.voice-sheet {
607 display: flex;
608 flex-direction: column;
609 gap: 10px;
610 width: min(420px, 100%);
611 padding: 20px;
612 border: 1px solid var(--shell-hairline);
613 border-radius: 18px;
614 background: var(--shell-surface);
615 box-shadow: var(--shell-shadow);
616 color: var(--shell-ink);
617}
618.voice-sheet-title { font-size: 15px; font-weight: 600; }
619.voice-sheet-hint,
620.voice-sheet-caption { color: var(--shell-ink-muted); font-size: 12px; line-height: 1.6; }
621.voice-sheet-row { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
622.voice-sheet-row select {
623 padding: 7px 9px;
624 border: 1px solid var(--shell-hairline);
625 border-radius: 10px;
626 background: var(--shell-field);
627 color: var(--shell-ink);
628 font-size: 12.5px;
629}
630.voice-sheet-level { height: 6px; border-radius: 999px; background: var(--shell-active); overflow: hidden; }
631.voice-sheet-level-bar { display: block; height: 100%; background: var(--primary); transform-origin: left center; transition: transform 60ms linear; }
632.voice-sheet-preview { width: 100%; max-height: 168px; border-radius: 12px; background: #000; object-fit: cover; }
633.voice-sheet-check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; }
634.voice-sheet-notice { color: var(--error-foreground); font-size: 12px; }
635.voice-sheet-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
636
637/* 折叠的连接细节:把 SDK 原文留给排查,不透给普通成员。 */
638.voice-debug { width: 100%; color: var(--shell-ink-muted); font-size: 11.5px; }
639.voice-debug summary { cursor: pointer; }
640.voice-debug code { display: block; margin-top: 4px; overflow-x: auto; font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; }
641
642.voice-join-members { color: var(--shell-ink-muted); font-size: 12px; }
643
644/* 只有自己在频道里:把邀请提示留在画布中央。 */
645.voice-empty {
646 position: absolute;
647 inset: 0;
648 display: grid;
649 place-items: center;
650 pointer-events: none;
651 color: var(--shell-ink-muted);
652 font-size: 12.5px;
653}
654
655/* 键盘/读屏:加入离开播报只进辅助技术。 */
656.voice-sr-only {
657 position: absolute;
658 width: 1px;
659 height: 1px;
660 overflow: hidden;
661 clip-path: inset(50%);
662 white-space: nowrap;
663}
Modified web/src/features/chat/index.ts web/src/features/chat/index.ts +6 −0 View web/src/features/chat/index.ts
@@ -5,5 +5,11 @@
55// Consumers import from '@/features/chat', never from deeper files.
66export { ChatRoom, type ChatRoomProps } from './ui/chat-room'
77export { prefetchRoomView } from './model/prefetch'
8export {
9 installVoiceSession,
10 joinVoiceRoom,
11 voiceCredentials,
12 type JoinVoiceRoomOptions,
13} from './model/voice-connection'
814export { VoiceCallBar } from './ui/voice/voice-call-bar'
915export type { ChatPanelKind, ChatViewState } from './model/view-state'
Modified web/src/features/search/ui/global-search-command.tsx web/src/features/search/ui/global-search-command.tsx +1 −1 View web/src/features/search/ui/global-search-command.tsx
@@ -112,7 +112,7 @@
112112 <button
113113 type="button"
114114 onClick={() => setOpen(true)}
115 className="fixed right-4 top-3 z-40 hidden h-9 w-9 items-center justify-center gap-2 rounded-lg border border-shell-hairline bg-shell-surface px-0 text-[12px] text-shell-ink-muted shadow-sm transition-colors hover:bg-shell-field md:flex lg:w-[clamp(200px,22vw,300px)] lg:justify-start lg:px-3"
115 className="global-search-trigger fixed right-4 top-3 z-40 hidden h-9 w-9 items-center justify-center gap-2 rounded-lg border border-shell-hairline bg-shell-surface px-0 text-[12px] text-shell-ink-muted shadow-sm transition-colors hover:bg-shell-field md:flex lg:w-[clamp(200px,22vw,300px)] lg:justify-start lg:px-3"
116116 aria-label={t('search.command.open')}
117117 >
118118 <Search className="h-4 w-4 shrink-0" aria-hidden />
Modified web/src/shared/api/globals.d.ts web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts
Diff too large to display.
Modified web/src/shared/i18n/messages/en/chat.ts web/src/shared/i18n/messages/en/chat.ts +38 −1 View web/src/shared/i18n/messages/en/chat.ts
@@ -70,6 +70,7 @@
7070 'chat.voice.failed': 'Could not join the voice channel. Please retry.',
7171 'chat.voice.members': 'Members online',
7272 'chat.voice.empty': 'Nobody else is in this channel yet',
73 'chat.voice.membersCount': '{count} members are already here',
7374 'chat.search.placeholder': 'Search messages',
7475 'chat.search.empty': 'No matching messages',
7576 'chat.member.list': 'Members',
@@ -293,9 +294,45 @@
293294 'chat.voice.notice.cameraUnavailable': 'The camera is unavailable',
294295 'chat.voice.notice.screenUnavailable': 'Screen sharing is unavailable',
295296 'chat.voice.notice.audioBlocked': 'The browser still blocks audio playback — check the sound permission for this site',
296 'chat.voice.notice.mediaUnreachable': 'The voice channel could not be established: the browser cannot reach the LiveKit media ports (UDP 50000 / TCP 7881). Make sure LiveKit is running and retry',
297 'chat.voice.notice.mediaUnreachable': 'The voice channel could not be established: the browser cannot reach the voice service. Rejoin; contact an administrator if it keeps failing.',
297298 'chat.voice.notice.permissionDenied': 'The browser denied microphone/camera permission — restore it from the address bar and retry',
298299 'chat.voice.notice.tokenInvalid': 'The voice token is invalid or expired — join again',
300 'chat.voice.notice.deviceMissing': 'No usable device found - check that a microphone/camera is connected.',
301 'chat.voice.notice.deviceInUse': 'The device is in use by another application.',
302 'chat.voice.notice.browserUnsupported': 'This browser cannot do voice calls - use Chrome or Edge.',
303 'chat.voice.mutedByHost': 'The host muted your microphone.',
304 'chat.voice.unmutedByHost': 'The host gave you the microphone back.',
305 'chat.voice.reconnecting': 'Voice connection dropped - reconnecting automatically...',
306 'chat.voice.reconnectFailed': 'Automatic reconnection failed. Select rejoin to try again.',
307 'chat.voice.rejoin': 'Rejoin',
308 'chat.voice.deviceSystemDefault': 'System default',
309 'chat.voice.deviceOff': 'Off',
310 'chat.voice.joinWithMic': 'Open the microphone when joining',
311 'chat.voice.deviceNoMic': 'Allow microphone access to list devices',
312 'chat.voice.deviceNoOutput': 'System default output',
313 'chat.voice.deviceNoCamera': 'Allow camera access to list devices',
314 'chat.voice.debugDetails': 'Connection details',
315 'chat.voice.deviceTitle': 'Devices & preview',
316 'chat.voice.deviceHint': 'Pick your devices, then join. The microphone follows your choice.',
317 'chat.voice.deviceJoin': 'Join channel',
318 'chat.voice.speaker': 'Speaker',
319 'chat.voice.micTest': 'Say something - the level bar below will move',
320 'chat.voice.micLevel': 'Microphone level',
321 'chat.voice.volume': 'Volume for {name}',
322 'chat.voice.focus': 'Focus {name}',
323 'chat.voice.clearFocus': 'Clear focus',
324 'chat.voice.shareAudio': 'Also share computer sound',
325 'chat.voice.shareCancelled': 'Screen share cancelled',
326 'chat.voice.autoLayout': 'Reset to automatic layout',
327 'chat.voice.prevPage': 'Previous page',
328 'chat.voice.nextPage': 'Next page',
329 'chat.voice.aria.joined': '{name} joined the call',
330 'chat.voice.aria.left': '{name} left the call',
331 'chat.voice.tileMenu': 'Actions for {name}',
332 'chat.voice.qualityHigh': 'Good connection',
333 'chat.voice.qualityMedium': 'Fair connection',
334 'chat.voice.qualityLow': 'Poor connection',
335 'chat.voice.qualityUnknown': 'Connection quality unknown',
299336
300337 // Bullet comments (danmaku).
301338 'chat.danmaku.title': 'Bullet comments',
Modified web/src/shared/i18n/messages/en/index.ts web/src/shared/i18n/messages/en/index.ts +2 −0 View web/src/shared/i18n/messages/en/index.ts
@@ -6,6 +6,7 @@
66import { errors } from './errors'
77import { issueEvents, issues } from './issues'
88import { labels } from './labels'
9import { livekit } from './livekit'
910import { misc } from './misc'
1011import { nav } from './nav'
1112import { ui } from './ui'
@@ -26,6 +27,7 @@
2627 ...errors,
2728 ...shell,
2829 ...labels,
30 ...livekit,
2931 ...settings,
3032 ...chat,
3133 ...chatComposer,
Modified web/src/shared/i18n/messages/zh-CN/chat.ts web/src/shared/i18n/messages/zh-CN/chat.ts +38 −1 View web/src/shared/i18n/messages/zh-CN/chat.ts
@@ -70,6 +70,7 @@
7070 'chat.voice.failed': '无法连接语音频道,请重试。',
7171 'chat.voice.members': '在线成员',
7272 'chat.voice.empty': '频道内还没有其他人',
73 'chat.voice.membersCount': '已有 {count} 人在频道里',
7374 'chat.search.placeholder': '搜索消息',
7475 'chat.search.empty': '没有匹配的消息',
7576 'chat.member.list': '成员',
@@ -293,9 +294,45 @@
293294 'chat.voice.notice.cameraUnavailable': '无法访问摄像头',
294295 'chat.voice.notice.screenUnavailable': '无法共享屏幕',
295296 'chat.voice.notice.audioBlocked': '浏览器仍阻止了音频播放,请检查站点声音权限',
296 'chat.voice.notice.mediaUnreachable': '语音通道建立失败:浏览器连不上 LiveKit 媒体端口(UDP 50000 / TCP 7881),请确认 LiveKit 已启动后重试',
297 'chat.voice.notice.mediaUnreachable': '语音通道没有建立起来:浏览器连不上语音服务。请重新加入;一直失败请联系管理员。',
297298 'chat.voice.notice.permissionDenied': '浏览器拒绝了麦克风/摄像头权限,请在地址栏恢复后重试',
298299 'chat.voice.notice.tokenInvalid': '语音令牌无效或已过期,请重新加入',
300 'chat.voice.notice.deviceMissing': '没有找到可用的设备,请检查麦克风/摄像头是否连接。',
301 'chat.voice.notice.deviceInUse': '设备被其他应用占用,请关闭占用后重试。',
302 'chat.voice.notice.browserUnsupported': '当前浏览器不支持语音通话,请使用 Chrome 或 Edge。',
303 'chat.voice.mutedByHost': '你已被主持人静音。',
304 'chat.voice.unmutedByHost': '主持人已恢复你的发言权。',
305 'chat.voice.reconnecting': '语音连接中断,正在自动重连…',
306 'chat.voice.reconnectFailed': '语音自动重连失败,请点击重新加入。',
307 'chat.voice.rejoin': '重新加入',
308 'chat.voice.deviceSystemDefault': '系统默认',
309 'chat.voice.deviceOff': '关闭',
310 'chat.voice.joinWithMic': '进入频道时开启麦克风',
311 'chat.voice.deviceNoMic': '授权后才能看到麦克风列表',
312 'chat.voice.deviceNoOutput': '系统默认输出',
313 'chat.voice.deviceNoCamera': '授权后才能看到摄像头列表',
314 'chat.voice.debugDetails': '连接细节',
315 'chat.voice.deviceTitle': '设备与预览',
316 'chat.voice.deviceHint': '选好设备后直接进入频道,麦克风按你的选择开启。',
317 'chat.voice.deviceJoin': '进入频道',
318 'chat.voice.speaker': '扬声器',
319 'chat.voice.micTest': '说句话,下面的音量条会跟着动',
320 'chat.voice.micLevel': '麦克风音量',
321 'chat.voice.volume': '{name} 的音量',
322 'chat.voice.focus': '聚焦 {name}',
323 'chat.voice.clearFocus': '取消聚焦',
324 'chat.voice.shareAudio': '同时共享电脑声音',
325 'chat.voice.shareCancelled': '已取消屏幕共享',
326 'chat.voice.autoLayout': '恢复自动排列',
327 'chat.voice.prevPage': '上一页',
328 'chat.voice.nextPage': '下一页',
329 'chat.voice.aria.joined': '{name} 加入了语音',
330 'chat.voice.aria.left': '{name} 离开了语音',
331 'chat.voice.tileMenu': '{name} 的操作',
332 'chat.voice.qualityHigh': '网络良好',
333 'chat.voice.qualityMedium': '网络一般',
334 'chat.voice.qualityLow': '网络较差',
335 'chat.voice.qualityUnknown': '网络状态未知',
299336
300337 // Bullet comments (danmaku).
301338 'chat.danmaku.title': '弹幕记录',
Modified web/src/shared/i18n/messages/zh-CN/index.ts web/src/shared/i18n/messages/zh-CN/index.ts +2 −0 View web/src/shared/i18n/messages/zh-CN/index.ts
@@ -5,6 +5,7 @@
55import { errors } from './errors'
66import { issueEvents, issues } from './issues'
77import { labels } from './labels'
8import { livekit } from './livekit'
89import { misc } from './misc'
910import { nav } from './nav'
1011import { ui } from './ui'
@@ -26,6 +27,7 @@
2627 ...errors,
2728 ...shell,
2829 ...labels,
30 ...livekit,
2931 ...settings,
3032 ...chat,
3133 ...chatComposer,
Modified web/src/shared/voice/index.ts web/src/shared/voice/index.ts +11 −2 View web/src/shared/voice/index.ts
@@ -1,3 +1,12 @@
1export { voiceSession, participantTracks, storedVoiceSession } from './session'
2export type { JoinOptions, StoredSession, VoiceSessionSnapshot, VoiceStatus } from './session'
1// Public API of the shared/voice segment.
2export { voiceSession, storedVoiceSession } from './session'
3export type {
4 JoinOptions,
5 StoredSession,
6 VoiceCredentialSource,
7 VoiceCredentials,
8 VoiceSessionSnapshot,
9 VoiceStatus,
10} from './session'
311export { useVoiceSession } from './react'
12export { VoiceHost, VoiceProvider } from './host'
Modified web/src/shared/voice/session.ts web/src/shared/voice/session.ts +279 −160 View web/src/shared/voice/session.ts
@@ -5,29 +5,46 @@
55 * keeps the call up and the global call bar can drive it from anywhere.
66 *
77 * Design notes:
8 * - This file owns *connection* only. Rendering and remote-audio playback live
9 * in the vendored `@livekit/components-react` tree (see `host.tsx`), which is
10 * fed the same Room through `RoomContext`.
811 * - A refresh restores the room from sessionStorage, but always muted: the
912 * browser will not autoplay audio without a gesture, and silently rejoining a
1013 * call with an open microphone is worse than one extra click.
1114 * - 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 * `useSyncExternalStore` consumers re-render only on real state changes.
16 * Per-participant state (speaking, mute, quality, volume) is deliberately NOT
17 * mirrored here: it is read from the Room by the official hooks instead, so
18 * there is exactly one source of truth for media state.
19 * - Failures are values (notice + detail), never throws: the room view stays
20 * usable when LiveKit is down, exactly like the chat socket's status banner.
21 * - `livekit-client@2.22` cannot swap the token of a live connection, so an
22 * expired credential is recovered by rejoining with a freshly issued one.
1523 */
1624import {
25 ConnectionError,
1726 ConnectionState,
27 DeviceUnsupportedError,
1828 DisconnectReason,
29 LivekitError,
30 MediaDeviceFailure,
1931 Room,
2032 RoomEvent,
21 Track,
2233 type LocalParticipant,
23 type Participant,
2434 type RemoteParticipant,
35 type ScreenShareCaptureOptions,
2536} from 'livekit-client'
2637import { t } from '@/shared/i18n'
2738
2839/** Persisted so a refresh can rejoin the same channel. */
2940const STORAGE_KEY = 'gitdata.voice.session'
3041
42/**
43 * Backoff for the automatic rejoin. Every attempt pulls a *new* token, which is
44 * what makes long calls survive the token TTL.
45 */
46const REJOIN_DELAYS_MS = [1_000, 3_000]
47
3148export type VoiceStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'failed'
3249
3350export interface VoiceSessionSnapshot {
@@ -38,16 +55,25 @@
3855 spaceName: string | null
3956 /** Set when a moderator removed this participant: the bar explains it. */
4057 notice: string | null
58 /** Raw SDK/browser message, shown behind the "connection details" disclosure. */
59 noticeDetail: string | null
4160 micEnabled: boolean
4261 cameraEnabled: boolean
4362 screenShareEnabled: boolean
4463 /** False until the browser has been allowed to play remote audio. */
4564 audioPlaybackAllowed: boolean
65 /** Local speak indicator only; remote tiles read the Room directly. */
4666 speaking: boolean
4767 /** True once a publish permission flip landed (Stage approval). */
4868 canPublish: boolean
4969 isStage: boolean
5070 hasRaisedHand: boolean
71 /** Host revoked our microphone during this call (distinct from Stage queue). */
72 mutedByHost: boolean
73 /** An automatic rejoin is scheduled/in flight. */
74 rejoining: boolean
75 /** Auto-rejoin attempts are exhausted: only a manual action can recover. */
76 needsManualRejoin: boolean
5177}
5278
5379export interface StoredSession {
@@ -56,18 +82,28 @@
5682 spaceName?: string
5783}
5884
59export interface JoinOptions {
85/** Credentials for one join. Supplied by the app layer to keep `shared` free of API imports. */
86export interface VoiceCredentials {
87 url: string
88 token: string
89 canPublish: boolean
90 isStage: boolean
91}
92
93export type VoiceCredentialSource = (roomId: string) => Promise<VoiceCredentials>
94
95export interface JoinOptions extends VoiceCredentials {
6096 roomId: string
6197 /** Channel name for the global call bar. */
6298 roomLabel?: string
6399 /** Owning space slug, so "回到语音频道" can rebuild the route. */
64100 spaceName?: string
65 url: string
66 token: string
67 canPublish: boolean
68 isStage: boolean
69101 /** Voice channels join with the microphone on; Stage audiences stay muted. */
70102 startWithMic: boolean
103 /** Chosen in the device sheet; omitted means "whatever the browser prefers". */
104 audioDeviceId?: string | null
105 videoDeviceId?: string | null
106 audioOutputDeviceId?: string | null
71107}
72108
73109const INITIAL: VoiceSessionSnapshot = {
@@ -76,6 +112,7 @@
76112 roomLabel: null,
77113 spaceName: null,
78114 notice: null,
115 noticeDetail: null,
79116 micEnabled: false,
80117 cameraEnabled: false,
81118 screenShareEnabled: false,
@@ -84,6 +121,9 @@
84121 canPublish: true,
85122 isStage: false,
86123 hasRaisedHand: false,
124 mutedByHost: false,
125 rejoining: false,
126 needsManualRejoin: false,
87127}
88128
89129type Listener = (snapshot: VoiceSessionSnapshot) => void
@@ -95,11 +135,17 @@
95135 private readonly participantNames = new Map<string, string>()
96136 /** Distinguishes a user-initiated teardown from an ICE/signal drop. */
97137 private leaving = false
98 /** True once the media peer connection reached the connected state. */
99 private hadMedia = false
100 /** Remote audio elements, keyed by track sid: a subscribed track is silent
101 * until it is attached to one, and the SDK never attaches it by itself. */
102 private readonly attachedAudio = new Map<string, { track: Track; element: HTMLMediaElement }>()
138 private credentialSource: VoiceCredentialSource | null = null
139 private rejoinTimer = 0
140 private rejoinAttempt = 0
141 /** Last join parameters, replayed verbatim on an automatic rejoin. */
142 private lastJoin: JoinOptions | null = null
143 /**
144 * The SDK disconnects with a client-initiated reason when the page is being
145 * torn down, which looks exactly like a hang-up. A reload must still restore
146 * the call, so the teardown is observed separately.
147 */
148 private unloading = false
103149
104150 getSnapshot(): VoiceSessionSnapshot {
105151 return this.snapshot
@@ -117,6 +163,21 @@
117163 return this.participantNames.get(identity) ?? identity
118164 }
119165
166 /** Lets the app layer hand over a token fetcher without `shared` importing `entities`. */
167 setCredentialSource(source: VoiceCredentialSource): void {
168 this.credentialSource = source
169 if (typeof window === 'undefined' || (window as Window & { lkUnloadBound?: boolean }).lkUnloadBound) return
170 ;(window as Window & { lkUnloadBound?: boolean }).lkUnloadBound = true
171 const onHide = () => {
172 this.unloading = true
173 }
174 const onShow = () => {
175 this.unloading = false
176 }
177 window.addEventListener('pagehide', onHide)
178 window.addEventListener('pageshow', onShow)
179 }
180
120181 private patch(updates: Partial<VoiceSessionSnapshot>): void {
121182 const next = { ...this.snapshot, ...updates }
122183 let changed = false
@@ -163,48 +224,27 @@
163224 } catch {
164225 // Private mode / disabled storage: the call still works, only the
165226 // refresh restore is lost.
166 }
167 }
168
169 /**
170 * Browsers only reveal their own host candidates once the page may touch the
171 * microphone, and a LiveKit host on WSL can only pair through loopback here.
172 * Asking up front puts the prompt on the user's click and makes the media
173 * channel reachable; a denial still joins in listen-only mode.
174 */
175 private async primeMicrophone(): Promise<void> {
176 try {
177 const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
178 for (const track of stream.getTracks()) track.stop()
179 } catch {
180 this.patch({ notice: t('chat.voice.notice.micDenied') })
181 }
182 }
183
184 private async microphoneAlreadyGranted(): Promise<boolean> {
185 try {
186 const status = await navigator.permissions.query({ name: 'microphone' as PermissionName })
187 return status.state === 'granted'
188 } catch {
189 return false
190227 }
191228 }
192229
193230 async join(options: JoinOptions): Promise<void> {
194231 if (this.room && this.snapshot.roomId === options.roomId) return
195232 if (this.room) await this.leave()
233 this.cancelRejoin()
196234 const room = new Room({ adaptiveStream: true, dynacast: true })
197235 this.room = room
198236 this.leaving = false
199 this.hadMedia = false
237 this.lastJoin = options
200238 this.patch({
201 status: 'connecting',
239 status: this.rejoinAttempt > 0 ? 'reconnecting' : 'connecting',
202240 roomId: options.roomId,
203241 roomLabel: options.roomLabel ?? null,
204242 spaceName: options.spaceName ?? null,
205243 notice: null,
244 noticeDetail: null,
206245 canPublish: options.canPublish,
207246 isStage: options.isStage,
247 needsManualRejoin: false,
208248 micEnabled: false,
209249 cameraEnabled: false,
210250 screenShareEnabled: false,
@@ -213,21 +253,22 @@
213253 this.bind(room)
214254
215255 try {
216 if (options.canPublish && (options.startWithMic || (await this.microphoneAlreadyGranted()))) {
217 await this.primeMicrophone()
218 }
219256 await room.connect(options.url, options.token)
220 this.adoptAudioTracks(room)
221257 this.patch({ status: 'connected' })
222258 this.persist(options.roomId)
259 if (options.audioOutputDeviceId) {
260 await this.applyOutputDevice(room, options.audioOutputDeviceId)
261 }
223262 if (options.canPublish && options.startWithMic) {
224 await this.setMicrophone(true)
263 await this.setMicrophone(true, options.audioDeviceId)
264 }
265 if (options.canPublish && options.videoDeviceId && options.startWithMic) {
266 await this.setCamera(true, options.videoDeviceId)
225267 }
268 this.rejoinAttempt = 0
226269 } catch (error) {
227 const message = error instanceof Error ? error.message : t('chat.voice.notice.connectFailed')
228 this.releaseAudio()
229 this.room = null
230 this.leaving = true
270 const described = describeVoiceFailure(error)
271 this.releaseRoom(room)
231272 this.patch({
232273 status: 'failed',
233274 roomId: null,
@@ -235,76 +276,113 @@
235276 cameraEnabled: false,
236277 screenShareEnabled: false,
237278 speaking: false,
238 notice: this.snapshot.notice ?? describeVoiceFailure(message),
279 rejoining: false,
280 notice: this.snapshot.notice ?? described.notice,
281 noticeDetail: this.snapshot.noticeDetail ?? described.detail,
239282 })
240283 throw error
241284 }
242285 }
243286
287 /** Switch the speaker/headset without a reconnect. */
288 private async applyOutputDevice(room: Room, deviceId: string): Promise<void> {
289 try {
290 await room.switchActiveDevice('audiooutput', deviceId)
291 } catch {
292 // Unsupported browser (Safari): the default route keeps working.
293 }
294 }
295
296 /** Route an output device change requested from the settings menu. */
297 async setAudioOutputDevice(deviceId: string): Promise<void> {
298 const room = this.room
299 if (!room) return
300 try {
301 await room.switchActiveDevice('audiooutput', deviceId)
302 if (this.lastJoin) this.lastJoin = { ...this.lastJoin, audioOutputDeviceId: deviceId }
303 } catch (error) {
304 const described = describeVoiceFailure(error)
305 this.patch({ notice: described.notice, noticeDetail: described.detail })
306 }
307 }
308
309 private releaseRoom(room: Room): void {
310 if (this.room === room) this.room = null
311 }
312
244313 private bind(room: Room): void {
314 let couldPublish = this.snapshot.canPublish
245315 const refreshSelf = () => {
246316 if (this.room !== room) return
247317 const local = room.localParticipant
248318 this.captureNames([...room.remoteParticipants.values()], local)
319 const canPublish = local.permissions?.canPublish ?? couldPublish
320 if (couldPublish && !canPublish) {
321 // A mid-call flip is a moderation action, so say who did what.
322 this.patch({ mutedByHost: !this.snapshot.isStage, notice: t('chat.voice.mutedByHost') })
323 } else if (!couldPublish && canPublish) {
324 this.patch({ mutedByHost: false, notice: t('chat.voice.unmutedByHost') })
325 }
326 couldPublish = canPublish
249327 this.patch({
250328 micEnabled: local.isMicrophoneEnabled,
251329 cameraEnabled: local.isCameraEnabled,
252330 screenShareEnabled: local.isScreenShareEnabled,
253 canPublish: local.permissions?.canPublish ?? this.snapshot.canPublish,
331 canPublish,
254332 speaking: local.isSpeaking,
255333 })
256334 }
257335
258336 room
259337 .on(RoomEvent.ConnectionStateChanged, (state: ConnectionState) => {
260 if (state === ConnectionState.Connected) {
261 this.hadMedia = true
262 this.patch({ status: 'connected' })
263 } else if (state === ConnectionState.Reconnecting) this.patch({ status: 'reconnecting' })
338 if (state === ConnectionState.Connected) this.patch({ status: 'connected' })
339 else if (state === ConnectionState.Reconnecting) this.patch({ status: 'reconnecting' })
264340 })
265341 .on(RoomEvent.Reconnected, () => {
266 // Re-subscribed tracks arrive as new objects; the stale elements go first.
267 this.releaseAudio()
268 this.adoptAudioTracks(room)
269 this.patch({ status: 'connected', notice: null })
342 this.cancelRejoin()
343 this.rejoinAttempt = 0
344 this.patch({ status: 'connected', notice: null, noticeDetail: null, rejoining: false })
270345 })
271346 .on(RoomEvent.MediaDevicesError, (error: Error) => {
272 this.patch({ notice: describeVoiceFailure(error.message) })
347 const described = describeVoiceFailure(error)
348 this.patch({ notice: described.notice, noticeDetail: described.detail })
273349 })
274350 .on(RoomEvent.ParticipantConnected, () => refreshSelf())
275351 .on(RoomEvent.ParticipantDisconnected, () => refreshSelf())
276 .on(RoomEvent.TrackSubscribed, (track, publication) => {
277 this.attachAudio(track, publication.trackSid)
278 })
279 .on(RoomEvent.TrackUnsubscribed, (_track, publication) => {
280 this.detachAudio(publication.trackSid)
281 })
282352 .on(RoomEvent.LocalTrackPublished, () => refreshSelf())
283353 .on(RoomEvent.LocalTrackUnpublished, () => refreshSelf())
284354 .on(RoomEvent.TrackMuted, () => refreshSelf())
285355 .on(RoomEvent.TrackUnmuted, () => refreshSelf())
286356 .on(RoomEvent.ActiveSpeakersChanged, () => refreshSelf())
287357 .on(RoomEvent.ParticipantPermissionsChanged, () => refreshSelf())
358 .on(RoomEvent.MediaDevicesChanged, () => refreshSelf())
288359 .on(RoomEvent.AudioPlaybackStatusChanged, () => {
289360 this.patch({ audioPlaybackAllowed: room.canPlaybackAudio })
290361 })
291 .on(RoomEvent.Disconnected, reason => {
362 .on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
292363 // The SDK also disconnects on pagehide, and that must NOT clear the
293 // stored session — the next page load is expected to restore the call.
364 // stored session - the next page load is expected to restore the call.
294365 // Only reasons that make a return impossible drop the stored room.
295366 const kicked = reason === DisconnectReason.PARTICIPANT_REMOVED
296367 const leftForGood =
297368 kicked ||
298369 reason === DisconnectReason.ROOM_DELETED ||
299370 reason === DisconnectReason.DUPLICATE_IDENTITY
300 const intentional = this.leaving || reason === DisconnectReason.CLIENT_INITIATED
301 this.releaseAudio()
371 const hungUp = this.leaving || reason === DisconnectReason.CLIENT_INITIATED
372 const intentional = hungUp && !this.unloading
373 this.releaseRoom(room)
302374 const retainedNotice = this.snapshot.notice
303 if (this.room === room) this.room = null
304 if (leftForGood) this.persist(null)
375 const retainedDetail = this.snapshot.noticeDetail
376 // An explicit hang-up must not come back on the next page load, and it
377 // must not be auto-retried either - the official DisconnectButton
378 // disconnects without going through leave().
379 if (leftForGood || intentional) {
380 this.lastJoin = null
381 this.persist(null)
382 }
305383 this.patch({
306384 status: intentional || leftForGood ? 'idle' : 'failed',
307 roomId: null,
385 roomId: leftForGood ? null : this.snapshot.roomId,
308386 roomLabel: leftForGood ? null : this.snapshot.roomLabel,
309387 spaceName: leftForGood ? null : this.snapshot.spaceName,
310388 micEnabled: false,
@@ -319,52 +397,64 @@
319397 ? retainedNotice
320398 : intentional
321399 ? null
322 : this.hadMedia
323 ? t('chat.voice.notice.dropped')
324 : mediaUnreachableNotice(),
400 : t('chat.voice.reconnecting'),
401 noticeDetail: retainedDetail ?? (reason ? String(reason) : null),
325402 })
403 if (!intentional && !leftForGood) this.scheduleRejoin()
326404 })
327405 refreshSelf()
328406 }
329407
330 /**
331 * The room panel only renders video, so remote audio is adopted here — the
332 * session outlives every route, which keeps the call audible off the page.
333 */
334 private attachAudio(track: Track, sid: string): void {
335 if (track.kind !== Track.Kind.Audio) return
336 const existing = this.attachedAudio.get(sid)
337 // The same track must not be attached twice; a re-subscribed one replaces it.
338 if (existing?.track === track) return
339 if (existing) this.detachAudio(sid)
340 const element = track.attach()
341 element.style.display = 'none'
342 document.body.appendChild(element)
343 this.attachedAudio.set(sid, { track, element })
408 private cancelRejoin(): void {
409 if (this.rejoinTimer) {
410 clearTimeout(this.rejoinTimer)
411 this.rejoinTimer = 0
412 }
413 this.patch({ rejoining: false })
344414 }
345415
346 private detachAudio(sid: string): void {
347 const entry = this.attachedAudio.get(sid)
348 if (!entry) return
349 this.attachedAudio.delete(sid)
350 try {
351 entry.track.detach(entry.element)
352 } catch {
353 // Already detached by the SDK; the element still has to leave the DOM.
416 /** Rejoin with a freshly issued token; gives up after REJOIN_DELAYS_MS runs out. */
417 private scheduleRejoin(): void {
418 const last = this.lastJoin
419 if (!last || !this.credentialSource || this.rejoinTimer) return
420 if (this.rejoinAttempt >= REJOIN_DELAYS_MS.length) {
421 this.patch({
422 status: 'failed',
423 rejoining: false,
424 needsManualRejoin: true,
425 notice: t('chat.voice.reconnectFailed'),
426 })
427 return
354428 }
355 entry.element.remove()
429 const delay = REJOIN_DELAYS_MS[this.rejoinAttempt]
430 this.rejoinAttempt += 1
431 this.patch({ rejoining: true, status: 'reconnecting' })
432 this.rejoinTimer = window.setTimeout(() => {
433 this.rejoinTimer = 0
434 void this.rejoin()
435 }, delay)
356436 }
357437
358 private releaseAudio(): void {
359 for (const sid of [...this.attachedAudio.keys()]) this.detachAudio(sid)
360 }
361
362 /** Tracks subscribed while connecting or reconnecting never re-emit, so adopt them. */
363 private adoptAudioTracks(room: Room): void {
364 for (const participant of room.remoteParticipants.values()) {
365 for (const [sid, publication] of participant.audioTrackPublications) {
366 if (publication.track) this.attachAudio(publication.track, sid)
367 }
438 /** Automatic path (after the backoff) and manual "rejoin" button share this. */
439 async rejoin(): Promise<boolean> {
440 const last = this.lastJoin
441 const source = this.credentialSource
442 if (!last || !source) return false
443 this.patch({ notice: t('chat.voice.reconnecting'), rejoining: true })
444 try {
445 const credentials = await source(last.roomId)
446 await this.join({ ...last, ...credentials })
447 this.cancelRejoin()
448 this.patch({ rejoining: false, needsManualRejoin: false })
449 return true
450 } catch (error) {
451 const described = describeVoiceFailure(error)
452 this.patch({
453 notice: this.snapshot.notice ?? described.notice,
454 noticeDetail: this.snapshot.noticeDetail ?? described.detail,
455 })
456 this.scheduleRejoin()
457 return false
368458 }
369459 }
370460
@@ -378,8 +468,10 @@
378468 async leave(): Promise<void> {
379469 const room = this.room
380470 this.leaving = true
471 this.cancelRejoin()
472 this.rejoinAttempt = 0
473 this.lastJoin = null
381474 this.room = null
382 this.releaseAudio()
383475 this.persist(null)
384476 this.patch({
385477 status: 'idle',
@@ -390,6 +482,11 @@
390482 cameraEnabled: false,
391483 screenShareEnabled: false,
392484 speaking: false,
485 mutedByHost: false,
486 needsManualRejoin: false,
487 rejoining: false,
488 notice: null,
489 noticeDetail: null,
393490 })
394491 if (!room) return
395492 try {
@@ -399,42 +496,57 @@
399496 }
400497 }
401498
402 async setMicrophone(enabled: boolean): Promise<void> {
499 async setMicrophone(enabled: boolean, deviceId?: string | null): Promise<void> {
403500 const room = this.room
404501 if (!room) return
405502 try {
406 await room.localParticipant.setMicrophoneEnabled(enabled)
503 await room.localParticipant.setMicrophoneEnabled(
504 enabled,
505 deviceId ? { deviceId: { exact: deviceId } } : undefined,
506 )
407507 this.patch({ micEnabled: enabled })
508 if (this.lastJoin) this.lastJoin = { ...this.lastJoin, audioDeviceId: deviceId ?? null }
408509 } catch (error) {
409 this.patch({
410 notice: error instanceof Error ? error.message : t('chat.voice.notice.micUnavailable'),
411 })
510 const described = describeVoiceFailure(error)
511 this.patch({ notice: described.notice, noticeDetail: described.detail })
412512 }
413513 }
414514
415 async setCamera(enabled: boolean): Promise<void> {
515 async setCamera(enabled: boolean, deviceId?: string | null): Promise<void> {
416516 const room = this.room
417517 if (!room) return
418518 try {
419 await room.localParticipant.setCameraEnabled(enabled)
519 await room.localParticipant.setCameraEnabled(
520 enabled,
521 deviceId ? { deviceId: { exact: deviceId } } : undefined,
522 )
420523 this.patch({ cameraEnabled: enabled })
524 if (this.lastJoin) this.lastJoin = { ...this.lastJoin, videoDeviceId: deviceId ?? null }
421525 } catch (error) {
422 this.patch({
423 notice: error instanceof Error ? error.message : t('chat.voice.notice.cameraUnavailable'),
424 })
526 const described = describeVoiceFailure(error)
527 this.patch({ notice: described.notice, noticeDetail: described.detail })
425528 }
426529 }
427530
428 async setScreenShare(enabled: boolean): Promise<void> {
531 /**
532 * `audio` decides whether the tab/window/system sound rides along; cancelling
533 * the browser picker is not a failure and is reported as such.
534 */
535 async setScreenShare(enabled: boolean, options?: ScreenShareCaptureOptions): Promise<boolean> {
429536 const room = this.room
430 if (!room) return
537 if (!room) return false
431538 try {
432 await room.localParticipant.setScreenShareEnabled(enabled, { audio: true })
539 await room.localParticipant.setScreenShareEnabled(enabled, { audio: true, ...options })
433540 this.patch({ screenShareEnabled: enabled })
541 return true
434542 } catch (error) {
435 this.patch({
436 notice: error instanceof Error ? error.message : t('chat.voice.notice.screenUnavailable'),
437 })
543 if (isUserCancellation(error)) {
544 this.patch({ notice: t('chat.voice.shareCancelled'), noticeDetail: null })
545 } else {
546 const described = describeVoiceFailure(error)
547 this.patch({ notice: described.notice, noticeDetail: described.detail })
548 }
549 return false
438550 }
439551 }
440552
@@ -455,56 +567,63 @@
455567 }
456568
457569 clearNotice(): void {
458 this.patch({ notice: null })
570 this.patch({ notice: null, noticeDetail: null })
459571 }
460572
461 /** LiveKit Room handle for the room view (tile rendering). */
573 /** LiveKit Room handle for the voice host (RoomContext + RoomAudioRenderer). */
462574 getRoom(): Room | null {
463575 return this.room
464576 }
465577}
466578
467const mediaUnreachableNotice = () => t('chat.voice.notice.mediaUnreachable')
579/** The user dismissed the picker; nothing went wrong. */
580function isUserCancellation(error: unknown): boolean {
581 return error instanceof Error && error.name === 'AbortError'
582}
468583
469/** Turn the SDK's English transport errors into something actionable. */
470function describeVoiceFailure(message: string): string {
471 const text = message.toLowerCase()
584interface VoiceFailure {
585 notice: string
586 detail: string | null
587}
588
589/**
590 * Classify by the SDK's error types and the browser's `DOMException.name`
591 * instead of matching English message substrings, which break whenever the SDK
592 * rewords a message.
593 */
594function describeVoiceFailure(error: unknown): VoiceFailure {
595 const detail = error instanceof Error ? error.message : String(error ?? '')
596 if (error instanceof Error && error.name === 'NotAllowedError') {
597 return { notice: t('chat.voice.notice.permissionDenied'), detail: detail || null }
598 }
599 const deviceFailure = error instanceof Error ? MediaDeviceFailure.getFailure(error) : undefined
600 if (deviceFailure === MediaDeviceFailure.PermissionDenied) {
601 return { notice: t('chat.voice.notice.permissionDenied'), detail: detail || null }
602 }
472603 if (
473 text.includes('pc connection') ||
474 text.includes('peerconnection') ||
475 text.includes('ice') ||
476 text.includes('timeout')
604 deviceFailure === MediaDeviceFailure.NotFound ||
605 (error instanceof Error && error.name === 'NotFoundError')
477606 ) {
478 return mediaUnreachableNotice()
607 return { notice: t('chat.voice.notice.deviceMissing'), detail: detail || null }
479608 }
480 if (text.includes('permission') || text.includes('notallowed') || text.includes('denied')) {
481 return t('chat.voice.notice.permissionDenied')
609 if (deviceFailure === MediaDeviceFailure.DeviceInUse) {
610 return { notice: t('chat.voice.notice.deviceInUse'), detail: detail || null }
611 }
612 if (error instanceof DeviceUnsupportedError) {
613 return { notice: t('chat.voice.notice.browserUnsupported'), detail: detail || null }
614 }
615 if (/token|unauthori[sz]ed|forbidden/i.test(detail)) {
616 return { notice: t('chat.voice.notice.tokenInvalid'), detail: detail || null }
482617 }
483 if (text.includes('token')) {
484 return t('chat.voice.notice.tokenInvalid')
618 if (error instanceof ConnectionError || error instanceof LivekitError) {
619 return { notice: t('chat.voice.notice.mediaUnreachable'), detail: detail || null }
485620 }
486 return message
621 return { notice: t('chat.voice.notice.connectFailed'), detail: detail || null }
487622}
488623
489624export const voiceSession = new VoiceSession()
490625
491/** The persisted call for this tab, if any — the refresh-restore entry point. */
626/** The persisted call for this tab, if any - the refresh-restore entry point. */
492627export function storedVoiceSession(): StoredSession | null {
493628 return VoiceSession.stored()
494}
495
496/** Stable helper for the tile list so both panels derive it identically. */
497export function participantTracks(participant: Participant): {
498 camera?: Track
499 screen?: Track
500} {
501 let camera: Track | undefined
502 let screen: Track | undefined
503 for (const publication of participant.trackPublications.values()) {
504 const track = publication.track
505 if (!track) continue
506 if (track.source === Track.Source.ScreenShare) screen = track
507 else if (track.source === Track.Source.Camera) camera = track
508 }
509 return { camera, screen }
510629}
Modified web/.oxlintrc.json web/.oxlintrc.json +1 −1 View web/.oxlintrc.json
@@ -1,7 +1,7 @@
11{
22 "$schema": "./node_modules/oxlint/configuration_schema.json",
33 "plugins": ["react", "typescript", "oxc"],
4 "ignorePatterns": ["src/shared/lib/pow-solver/**"],
4 "ignorePatterns": ["src/shared/lib/pow-solver/**", "src/shared/livekit/**"],
55 "rules": {
66 "react/rules-of-hooks": "error",
77 "react/only-export-components": ["warn", { "allowConstantExport": true }]
Modified web/bun.lock web/bun.lock +20 −0 View web/bun.lock
@@ -6,16 +6,24 @@
66 "name": "ui",
77 "dependencies": {
88 "@alova/wormhole": "^1.5.2",
9 "@floating-ui/dom": "^1.8.0",
10 "@livekit/protocol": "1.50.4",
911 "@tailwindcss/vite": "^4.3.3",
1012 "alova": "^3.5.5",
13 "clsx": "2.1.1",
1114 "dompurify": "^3.4.15",
15 "events": "^3.3.0",
1216 "livekit-client": "2.22.3",
17 "loglevel": "^1.9.2",
1318 "lucide-react": "^1.39.0",
1419 "marked": "^18.0.13",
1520 "react": "^19.2.8",
1621 "react-dom": "^19.2.8",
1722 "react-router-dom": "^7.18.3",
23 "rxjs": "7.8.2",
1824 "tailwindcss": "^4.3.3",
25 "typed-emitter": "^2.1.0",
26 "usehooks-ts": "3.1.1",
1927 },
2028 "devDependencies": {
2129 "@types/node": "^24.13.3",
@@ -102,6 +110,12 @@
102110
103111 "@feature-sliced/steiger-plugin": ["@feature-sliced/steiger-plugin@0.7.0", "", { "dependencies": { "@feature-sliced/filesystem": "^3.1.0", "fastest-levenshtein": "^1.0.16", "lodash-es": "^4.17.21", "pluralize": "^8.0.0", "tsconfck": "^3.1.6", "web-tree-sitter": "^0.26.8" } }, "sha512-jd90OaXYW/6sQbJ9LjRhN4HFLrrqpp436V+6vpJQGpp/CVd+hynaLDtDfsZSxMgfgj8uvcow3zwwKRCnBlLWYQ=="],
104112
113 "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
114
115 "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
116
117 "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
118
105119 "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
106120
107121 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -276,6 +290,8 @@
276290
277291 "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
278292
293 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
294
279295 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
280296
281297 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -418,6 +434,8 @@
418434
419435 "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
420436
437 "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
438
421439 "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
422440
423441 "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
@@ -602,6 +620,8 @@
602620
603621 "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
604622
623 "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
624
605625 "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
606626
607627 "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=="],
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.
Modified web/package.json web/package.json +10 −2 View web/package.json
@@ -13,16 +13,24 @@
1313 },
1414 "dependencies": {
1515 "@alova/wormhole": "^1.5.2",
16 "@floating-ui/dom": "^1.8.0",
17 "@livekit/protocol": "1.50.4",
1618 "@tailwindcss/vite": "^4.3.3",
1719 "alova": "^3.5.5",
20 "clsx": "2.1.1",
1821 "dompurify": "^3.4.15",
22 "events": "^3.3.0",
1923 "livekit-client": "2.22.3",
24 "loglevel": "^1.9.2",
2025 "lucide-react": "^1.39.0",
2126 "marked": "^18.0.13",
2227 "react": "^19.2.8",
2328 "react-dom": "^19.2.8",
2429 "react-router-dom": "^7.18.3",
25 "tailwindcss": "^4.3.3"
30 "rxjs": "7.8.2",
31 "tailwindcss": "^4.3.3",
32 "typed-emitter": "^2.1.0",
33 "usehooks-ts": "3.1.1"
2634 },
2735 "devDependencies": {
2836 "@types/node": "^24.13.3",
@@ -34,4 +42,4 @@
3442 "typescript": "~6.0.2",
3543 "vite": "^8.2.2"
3644 }
37}
45}
No newline at end of file
Modified web/steiger.config.js web/steiger.config.js +1 −0 View web/steiger.config.js
@@ -74,6 +74,7 @@
7474 '**/dist/**',
7575 'src/shared/api/**', // auto-generated by `bun run gen:api`
7676 'src/shared/lib/pow-solver/**', // vendored wasm-bindgen build
77 'src/shared/livekit/**', // vendored @livekit/components-react|core|styles
7778 ],
7879 },
7980])
Modified web/tsconfig.app.json web/tsconfig.app.json +3 −1 View web/tsconfig.app.json
@@ -23,7 +23,9 @@
2323 "@/widgets/*": ["./src/widgets/*"],
2424 "@/features/*": ["./src/features/*"],
2525 "@/entities/*": ["./src/entities/*"],
26 "@/shared/*": ["./src/shared/*"]
26 "@/shared/*": ["./src/shared/*"],
27 "@livekit/components-core": ["./src/shared/livekit/core/index.ts"],
28 "@livekit/components-styles/*": ["./src/shared/livekit/styles/*"]
2729 },
2830
2931 /* Linting */
Modified web/vite.config.ts web/vite.config.ts +10 −1 View web/vite.config.ts
@@ -6,7 +6,14 @@
66// FSD path aliases — keep the Vite resolver in sync with tsconfig.app.json "paths".
77const layer = (name: string) => fileURLToPath(new URL(`./src/${name}`, import.meta.url))
88
9// https://vite.dev/config/
9// The vendored LiveKit components (src/shared/livekit) are addressed through
10// their published package names on purpose: the official sources then stay
11// byte-identical and an upstream tarball can be replayed without touching a
12// single import statement.
13const vendored = (name: string) =>
14 fileURLToPath(new URL(`./src/shared/livekit/${name}`, import.meta.url))
15
16// https://vite.dev/
1017export default defineConfig({
1118 plugins: [react(), tailwindcss()],
1219 resolve: {
@@ -17,6 +24,8 @@
1724 '@/features': layer('features'),
1825 '@/entities': layer('entities'),
1926 '@/shared': layer('shared'),
27 '@livekit/components-core': vendored('core/index.ts'),
28 '@livekit/components-styles': vendored('styles'),
2029 },
2130 },
2231 server: {
Modified openapi.json openapi.json View openapi.json
Diff too large to display.