feat(voice): free-resize tiles and add a speaking avatar tile
Participant windows now carry a per-axis height, so a window can be wide and short or narrow and tall instead of only scaling in place; records written before the field existed keep the classic 3:4 shape. Remote moves are applied on a fixed cadence and interpolated, so watching somebody else drag no longer steps their window across the canvas in one jump per server write. An audio-only member now reads as a person rather than a broken window: their avatar sits inside a ring driven by the same analyser the official level meter used, trimmed by a room floor so an open mic in a quiet room stays a ring.
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/services/voice.rs lib/chat/src/services/voice.rs +9 −0 View lib/chat/src/services/voice.rs
@@ -48,7 +48,13 @@
4848pub struct VoiceTileDto {
4949 pub x: f64,
5050 pub y: f64,
51 /// 归一化宽度。
5152 pub w: f64,
53 /// 归一化高度,和 `w` 一样按画布的可用宽高分别归一,因此窗口可以自由
54 /// 拉伸而不是只能等比缩放。旧记录没有这个字段,读出来是 `None`,客户端
55 /// 按宽度的 3/4 补一个经典比例。
56 #[serde(default)]
57 pub h: Option<f64>,
5258 /// 服务端写入时刻(秒)。请求里被忽略,响应里供客户端判断“谁更新”,
5359 /// 这样并发拖拽时一个迟到的 GET 不会把刚摆好的窗口拽回旧位置。
5460 #[serde(default)]
@@ -210,6 +216,9 @@
210216 x = tonumber(tile.x),
211217 y = tonumber(tile.y),
212218 w = tonumber(tile.w),
219 -- 旧客户端(或旧记录回写)不带 h:tonumber(nil) 是 nil,这个键就不会写进
220 -- JSON,读出来仍是“未指定”,而不是塌成 0。
221 h = tonumber(tile.h),
213222 updated_at = stamp,
214223 }
215224 end
Modified web/src/entities/chat/model/voice-api.ts web/src/entities/chat/model/voice-api.ts +6 −1 View web/src/entities/chat/model/voice-api.ts
@@ -27,11 +27,16 @@
2727export type VoiceTile = VoiceTileDto
2828/** Shared layout as stored on the server: every tile carries its write stamp. */
2929export type VoiceLayoutTiles = Record<string, VoiceTileDto>
30/** What a client may write: coordinates only, the stamp is server-owned. */
30/**
31 * What a client may write: geometry only, the stamp is server-owned. `h` is
32 * optional on the wire so an older client's write stays valid — the server then
33 * leaves the height unset and readers fall back to the classic 3:4 shape.
34 */
3135export interface VoiceTileWrite {
3236 x: number
3337 y: number
3438 w: number
39 h?: number
3540}
3641export type VoiceHandState = HandState
3742
Added web/src/features/chat/ui/voice/voice-avatar-tile.tsx web/src/features/chat/ui/voice/voice-avatar-tile.tsx +106 −0 View web/src/features/chat/ui/voice/voice-avatar-tile.tsx
@@ -0,0 +1,106 @@
1/**
2 * The window shown for a member whose camera is off: their avatar inside a ring
3 * that reacts to their voice.
4 *
5 * The official `ParticipantAudioTile` fills the whole window with a seven-bar
6 * level meter, which is the wrong shape for a person — a full-height meter
7 * carries no identity, so an audio-only member read as a broken window rather
8 * than as a person. The avatar is what says *who* is talking, so the meter is
9 * reduced to the ring around it: one band of the same analyser the official bars
10 * used, with no extra audio plumbing.
11 */
12import { useMemo, type CSSProperties } from 'react'
13import { MicOff } from 'lucide-react'
14import { Track } from 'livekit-client'
15import {
16 isTrackReference,
17 useIsMuted,
18 useIsSpeaking,
19 useTrackVolume,
20 type TrackReferenceOrPlaceholder,
21} from '@/shared/livekit'
22import { useT } from '@/shared/i18n'
23import { UserAvatar } from '../parts'
24import { initials } from './voice-format'
25
26/**
27 * The analyser reports RMS frequency energy, which sits around 0.4–0.6 for an
28 * open microphone in a quiet room — a straight multiply pins the ring at its
29 * maximum and it reads as a static border. The band below is the room floor and
30 * the band above is a raised voice, so the ring only grows for actual speech.
31 */
32const LEVEL_FLOOR = 0.4
33const LEVEL_CEILING = 0.8
34/** Ring spread at full level: enough to be seen, small enough to stay a ring. */
35const LEVEL_SPREAD = 0.24
36
37/** Voice activity in 0…1, with the room floor subtracted and the rest stretched. */
38function spreadOf(level: number): number {
39 const trimmed = (level - LEVEL_FLOOR) / (LEVEL_CEILING - LEVEL_FLOOR)
40 return Math.min(1, Math.max(0, trimmed))
41}
42
43export function VoiceAvatarTile({
44 trackRef,
45 micRef,
46 avatarUrl,
47}: {
48 trackRef: TrackReferenceOrPlaceholder
49 /** The member's microphone: what the ring level is measured from. */
50 micRef?: TrackReferenceOrPlaceholder
51 avatarUrl?: string | null
52}) {
53 const { t } = useT()
54 const participant = trackRef.participant
55 const name = participant.name || initials(participant.identity)
56 const speaking = useIsSpeaking(participant)
57 /**
58 * A window with no camera still belongs to a member whose microphone *is* the
59 * thing producing sound, so the level and the mute flag are read from the
60 * microphone reference. Falling back to a constructed reference keeps the
61 * hooks unconditional when the caller has no entry for this identity.
62 */
63 const mic = useMemo<TrackReferenceOrPlaceholder>(
64 () =>
65 micRef ?? {
66 participant,
67 source: Track.Source.Microphone,
68 publication: participant.getTrackPublication(Track.Source.Microphone),
69 },
70 [micRef, participant],
71 )
72 const muted = useIsMuted(mic)
73 /* The volume analyser needs a live publication; an unsubscribed (placeholder)
74 microphone has no audio to measure, so the ring rests at its minimum. */
75 const liveMic = isTrackReference(mic) ? mic : undefined
76 const level = useTrackVolume(liveMic)
77 const style = useMemo(
78 () =>
79 ({
80 '--voice-level': String(spreadOf(level) * LEVEL_SPREAD),
81 }) as CSSProperties,
82 [level],
83 )
84
85 return (
86 <div
87 className="voice-avatar-tile lk-participant-tile"
88 data-lk-speaking={speaking}
89 data-lk-audio-muted={muted}
90 style={style}
91 title={speaking ? t('chat.voice.tileSpeaking', { name }) : name}
92 >
93 <span className="voice-avatar-ring" aria-hidden />
94 {/* Sized by CSS against the window, which the caller can resize. */}
95 <UserAvatar
96 user={{ display_name: name, username: name, avatar_url: avatarUrl ?? '' }}
97 id={participant.identity}
98 className="voice-avatar-face"
99 />
100 <span className="voice-avatar-name">
101 {muted ? <MicOff className="h-3 w-3 shrink-0" aria-hidden /> : null}
102 <span className="truncate">{name}</span>
103 </span>
104 </div>
105 )
106}
Modified web/src/features/chat/ui/voice/voice-room-panel.tsx web/src/features/chat/ui/voice/voice-room-panel.tsx +13 −0 View web/src/features/chat/ui/voice/voice-room-panel.tsx
@@ -178,6 +178,18 @@
178178 // eslint-disable-next-line react-hooks/exhaustive-deps
179179 }, [refreshHands, roomId, session.status])
180180
181 /** Avatar per user id: the presence snapshot is the only place carrying it. */
182 const avatarUrls = useMemo(
183 () =>
184 new Map(
185 participants.map(participant => [
186 String(participant.user_id),
187 participant.avatar_url ?? null,
188 ]),
189 ),
190 [participants],
191 )
192
181193 const mutedIds = useMemo(
182194 () =>
183195 new Set(
@@ -420,6 +432,7 @@
420432 canModerate={canModerate}
421433 layout={tileLayout}
422434 mutedIds={mutedIds}
435 avatarUrls={avatarUrls}
423436 onMute={onMute}
424437 onKick={onKick}
425438 />
Modified web/src/features/chat/ui/voice/voice-tile-layout.ts web/src/features/chat/ui/voice/voice-tile-layout.ts +191 −42 View web/src/features/chat/ui/voice/voice-tile-layout.ts
@@ -16,7 +16,11 @@
1616 * local tile when it is at least as new, which keeps an in-flight GET from
1717 * rewinding a window the user just parked;
1818 * - while a window is under the pointer it is frozen: no remote value is merged
19 * into the key being dragged.
19 * into the key being dragged;
20 * - a *remote* move is not drawn the moment it arrives: snapshots are collected
21 * and applied on a fixed cadence, and the window glides to each new placement
22 * (see REMOTE_APPLY_MS). Drawing every snapshot as it lands is what made a
23 * member watching somebody else drag see the window teleport once per push.
2024 */
2125import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
2226import {
@@ -32,8 +36,14 @@
3236 x: number
3337 /** 0 -> glued to the top edge, 1 -> glued to the bottom edge. */
3438 y: number
35 /** 0 -> smallest window, 1 -> largest window. */
39 /** 0 -> narrowest window, 1 -> widest window. */
3640 w: number
41 /**
42 * 0 -> shortest window, 1 -> tallest window. Independent of `w`, so a window can
43 * be stretched on one axis without the other. `undefined` marks a record written
44 * before the height existed: those keep the classic 3:4 shape (see `heightFor`).
45 */
46 h?: number
3747}
3848
3949export interface PlacedTile extends TilePlacement {
@@ -45,6 +55,7 @@
4555 left: number
4656 top: number
4757 width: number
58 height: number
4859}
4960
5061interface TileGesture {
@@ -63,46 +74,91 @@
6374const LAYOUT_PREFIX = 'gitdata.voice.tiles.'
6475const TILE_MIN_WIDTH = 140
6576const TILE_MAX_WIDTH = 640
77const TILE_MIN_HEIGHT = 140
78const TILE_MAX_HEIGHT = 760
6679const TILE_SNAP = 4
67const TILE_RATIO = 3 / 4
80/**
81 * 没有 `h` 的旧布局沿用这个高度系数。旧代码里窗口的高度是 CSS 的
82 * `aspect-ratio: 3 / 4` 定出来的,也就是 width : height = 3 : 4 的竖屏,
83 * 所以 height = width * 4/3。
84 */
85const TILE_LEGACY_ASPECT = 4 / 3
6886/** Mirrors the padding of the tile canvas: windows never cross it. */
6987const TILE_PAD_X = 16
7088const TILE_PAD_Y = 18
7189/** Shared-layout write throttle, so a drag stays live without flooding. */
7290const LAYOUT_PUSH_MS = 140
91/**
92 * Cadence for drawing *somebody else's* move. A drag reaches this tab as a burst
93 * of snapshots ~140ms apart, one per server write; applying each one would step
94 * the window across the canvas. Instead the newest snapshot is held and applied
95 * on this cadence, and the window interpolates across the gap (see the glide
96 * transition on `.voice-window.is-floating`) — the move then trails the person
97 * dragging it by about this much and travels continuously rather than in jumps.
98 */
99const REMOTE_APPLY_MS = 500
73100
74101const clamp01 = (value: number) => (Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0)
75102
103
104/**
105 * Per-axis spans of the canvas: the pixels a window may occupy on each axis, and
106 * the range its size is normalized against. The two axes are independent — a
107 * window is free to be wide and short or narrow and tall.
108 */
76109function spansOf(box: CanvasBox) {
77110 const usableW = Math.max(0, box.width - TILE_PAD_X * 2)
78111 const usableH = Math.max(0, box.height - TILE_PAD_Y * 2)
79 const maxW = Math.max(0, Math.min(TILE_MAX_WIDTH, usableW, usableH / TILE_RATIO))
80 return { usableW, usableH, maxW, minW: Math.min(TILE_MIN_WIDTH, maxW) }
112 const maxW = Math.max(0, Math.min(TILE_MAX_WIDTH, usableW))
113 const maxH = Math.max(0, Math.min(TILE_MAX_HEIGHT, usableH))
114 return {
115 usableW,
116 usableH,
117 maxW,
118 maxH,
119 minW: Math.min(TILE_MIN_WIDTH, maxW),
120 minH: Math.min(TILE_MIN_HEIGHT, maxH),
121 }
81122}
82123
83/** Normalized placement -> pixels; clamping to the span makes overflow impossible. */
124/**
125 * Normalized placement -> pixels. Clamping to the span makes overflow impossible,
126 * and both axes are derived from their own normalized value, so a resize that only
127 * changed `w` leaves the height exactly where it was.
128 *
129 * A placement with no `h` predates the field and keeps the classic 3:4 window. The
130 * two axes are normalized against different spans, so equal normalized values are
131 * *not* the old shape — reusing `w` directly would make every parked window jump
132 * taller. Deriving the height from the width in pixels and the old ratio is what
133 * leaves an existing layout looking exactly as its owner left it.
134 */
84135export function pixelsFor(box: CanvasBox, placement: TilePlacement): TileBox | null {
85 const { usableW, usableH, maxW, minW } = spansOf(box)
86 if (maxW <= 0) return null
136 const { usableW, usableH, maxW, maxH, minW, minH } = spansOf(box)
137 if (maxW <= 0 || maxH <= 0) return null
87138 const width = Math.round(minW + clamp01(placement.w) * (maxW - minW))
88 const height = width * TILE_RATIO
139 const height =
140 placement.h === undefined
141 ? Math.min(maxH, Math.round(width * TILE_LEGACY_ASPECT))
142 : Math.round(minH + clamp01(placement.h) * (maxH - minH))
89143 return {
90144 left: Math.round(TILE_PAD_X + clamp01(placement.x) * Math.max(0, usableW - width)),
91145 top: Math.round(TILE_PAD_Y + clamp01(placement.y) * Math.max(0, usableH - height)),
92146 width,
147 height,
93148 }
94149}
95150
96151function normalizedFor(box: CanvasBox, tile: TileBox): TilePlacement {
97 const { usableW, usableH, maxW, minW } = spansOf(box)
152 const { usableW, usableH, maxW, maxH, minW, minH } = spansOf(box)
98153 const width = Math.min(Math.max(minW, tile.width), maxW)
99 const height = width * TILE_RATIO
154 const height = Math.min(Math.max(minH, tile.height), maxH)
100155 const spanW = Math.max(1, usableW - width)
101156 const spanH = Math.max(1, usableH - height)
102157 return {
103158 x: clamp01((tile.left - TILE_PAD_X) / spanW),
104159 y: clamp01((tile.top - TILE_PAD_Y) / spanH),
105160 w: maxW <= minW ? 1 : clamp01((width - minW) / (maxW - minW)),
161 h: maxH <= minH ? 1 : clamp01((height - minH) / (maxH - minH)),
106162 }
107163}
108164
@@ -122,6 +178,7 @@
122178 x: clamp01(value.x),
123179 y: clamp01(value.y),
124180 w: clamp01(value.w),
181 h: typeof value.h === 'number' ? clamp01(value.h) : undefined,
125182 updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : 0,
126183 }
127184 }
@@ -143,7 +200,9 @@
143200}
144201
145202function toTile(placement: PlacedTile): VoiceTileWrite {
146 return { x: placement.x, y: placement.y, w: placement.w }
203 // A legacy placement that was only moved (never resized) keeps no height, so the
204 // write stays in the older shape and the server leaves `h` unset.
205 return { x: placement.x, y: placement.y, w: placement.w, ...(placement.h === undefined ? {} : { h: placement.h }) }
147206}
148207
149208export interface VoiceTileLayout {
@@ -182,6 +241,13 @@
182241 last: 0,
183242 timer: 0,
184243 })
244 /** Remote placements waiting for the next application slot, and that slot's timer. */
245 const remoteRef = useRef<{ pending: Record<string, PlacedTile>; timer: number }>({
246 pending: {},
247 timer: 0,
248 })
249 /** The room's layout has been read once: the first pull is hydration, not a move. */
250 const hydratedRef = useRef(false)
185251
186252 useEffect(() => {
187253 layoutRef.current = layout
@@ -248,6 +314,80 @@
248314 [roomId],
249315 )
250316
317 /**
318 * Placements from one snapshot that this tab should adopt, or null when the
319 * snapshot agrees with what is already on screen.
320 *
321 * Two values are deliberately left alone: the key under the local pointer,
322 * and any key this tab wrote more recently than the snapshot. Both stamps come
323 * from the same server clock, so a slow GET can never rewind a window the user
324 * has just parked.
325 */
326 const placementsFrom = useCallback(
327 (tiles: VoiceLayoutTiles): Record<string, PlacedTile> | null => {
328 const adopted: Record<string, PlacedTile> = {}
329 const frozen = draggingRef.current
330 for (const [key, tile] of Object.entries(tiles)) {
331 if (key === frozen) continue
332 const local = layoutRef.current[key]
333 const updatedAt = tile.updated_at ?? 0
334 if (local && local.updatedAt > updatedAt) continue
335 // A record written before height existed (or by an older client) keeps the
336 // classic shape: its height is resolved against the canvas it renders on.
337 const placement: PlacedTile = {
338 x: clamp01(tile.x),
339 y: clamp01(tile.y),
340 w: clamp01(tile.w),
341 h: typeof tile.h === 'number' ? clamp01(tile.h) : undefined,
342 updatedAt,
343 }
344 if (
345 local &&
346 local.x === placement.x &&
347 local.y === placement.y &&
348 local.w === placement.w &&
349 local.h === placement.h
350 ) {
351 continue
352 }
353 adopted[key] = placement
354 }
355 return Object.keys(adopted).length > 0 ? adopted : null
356 },
357 [],
358 )
359
360 /**
361 * Draw whatever the last slot collected. Reading the pending map *inside* the
362 * timer is what makes the newest snapshot win: a burst of pushes collapses
363 * into one application, so the window lands on a position its owner really
364 * had rather than on every intermediate one.
365 */
366 const flushRemote = useCallback(() => {
367 const slot = remoteRef.current
368 slot.timer = 0
369 const pending = slot.pending
370 slot.pending = {}
371 // Re-check every entry on the way out: the pointer may have landed on one of
372 // these windows while the slot was waiting, and a snapshot can have been
373 // superseded by a newer own write in the meantime.
374 const adopted = placementsFrom(
375 Object.fromEntries(
376 Object.entries(pending).map(([key, placement]) => [
377 key,
378 {
379 x: placement.x,
380 y: placement.y,
381 w: placement.w,
382 h: placement.h,
383 updated_at: placement.updatedAt,
384 },
385 ]),
386 ),
387 )
388 if (adopted) commit({ ...layoutRef.current, ...adopted })
389 }, [commit, placementsFrom])
390
251391 const pullRemote = useCallback(async () => {
252392 let tiles: VoiceLayoutTiles
253393 try {
@@ -256,35 +396,27 @@
256396 // No shared layout yet simply means the automatic grid.
257397 return
258398 }
259 const next = { ...layoutRef.current }
260 let changed = false
261 const frozen = draggingRef.current
262 for (const [key, tile] of Object.entries(tiles)) {
263 if (key === frozen) continue
264 const local = next[key]
265 const updatedAt = tile.updated_at ?? 0
266 // A window we wrote after the snapshot we are merging wins; otherwise the
267 // room does. Both stamps come from the same server clock, so a slow GET
268 // can never rewind a window the user has just parked.
269 if (local && local.updatedAt > updatedAt) continue
270 const placement: PlacedTile = {
271 x: clamp01(tile.x),
272 y: clamp01(tile.y),
273 w: clamp01(tile.w),
274 updatedAt,
275 }
276 if (
277 !local ||
278 local.x !== placement.x ||
279 local.y !== placement.y ||
280 local.w !== placement.w
281 ) {
282 next[key] = placement
283 changed = true
284 }
399 // The first read of a room is its state, not a move: it has to land at once,
400 // or joining a call would show every parked window sliding in from wherever
401 // this tab last saw it. The flag is cleared by the read itself rather than by
402 // finding something to adopt — a room whose windows all sit in the automatic
403 // grid answers the first pull with nothing, and the *next* pull is then the
404 // first real move and must still glide.
405 const hydrating = !hydratedRef.current
406 hydratedRef.current = true
407 const adopted = placementsFrom(tiles)
408 if (!adopted) return
409 if (hydrating) {
410 commit({ ...layoutRef.current, ...adopted })
411 return
285412 }
286 if (changed) commit(next)
287 }, [roomId, commit])
413 const slot = remoteRef.current
414 Object.assign(slot.pending, adopted)
415 // A slot is already waiting: it will pick this snapshot up when it fires, so
416 // the move never waits twice and a stream of pushes cannot starve it.
417 if (slot.timer) return
418 slot.timer = window.setTimeout(flushRemote, REMOTE_APPLY_MS)
419 }, [roomId, placementsFrom, commit, flushRemote])
288420
289421 const beginGesture = useCallback(
290422 (event: ReactPointerEvent<HTMLElement>, key: string, mode: 'move' | 'resize') => {
@@ -301,6 +433,7 @@
301433 left: tileRect.left - canvasRect.left + canvas.scrollLeft,
302434 top: tileRect.top - canvasRect.top + canvas.scrollTop,
303435 width: tileRect.width,
436 height: tileRect.height,
304437 }
305438 const parked = layoutRef.current[key]
306439 const origin = (parked ? pixelsFor(canvasSize, parked) : null) ?? rectBox
@@ -332,10 +465,22 @@
332465 const dy = event.clientY - active.startY
333466 if (Math.abs(dx) > 3 || Math.abs(dy) > 3) movedKeys.current.add(key)
334467 const snap = (value: number) => Math.round(value / TILE_SNAP) * TILE_SNAP
468 // Both axes follow their own delta: the handle is a free corner resize, not
469 // a proportional scale, so dragging straight down makes the window taller
470 // without touching its width.
335471 const next: TileBox =
336472 active.mode === 'move'
337 ? { left: snap(active.origin.left + dx), top: snap(active.origin.top + dy), width: active.origin.width }
338 : { ...active.origin, width: snap(active.origin.width + dx) }
473 ? {
474 left: snap(active.origin.left + dx),
475 top: snap(active.origin.top + dy),
476 width: active.origin.width,
477 height: active.origin.height,
478 }
479 : {
480 ...active.origin,
481 width: snap(active.origin.width + dx),
482 height: snap(active.origin.height + dy),
483 }
339484 commit({
340485 ...layoutRef.current,
341486 [key]: { ...normalizedFor(canvasSize, next), updatedAt: 0 },
@@ -387,10 +532,14 @@
387532
388533 useEffect(() => {
389534 const push = pushRef.current
535 const remote = remoteRef.current
390536 return () => {
391537 if (push.timer) clearTimeout(push.timer)
392538 push.timer = 0
393539 push.last = 0
540 if (remote.timer) clearTimeout(remote.timer)
541 remote.timer = 0
542 remote.pending = {}
394543 }
395544 }, [])
396545
Modified web/src/features/chat/ui/voice/voice-tiles.tsx web/src/features/chat/ui/voice/voice-tiles.tsx +58 −17 View web/src/features/chat/ui/voice/voice-tiles.tsx
@@ -22,12 +22,12 @@
2222 FocusLayout,
2323 FocusLayoutContainer,
2424 GridLayout,
25 ParticipantAudioTile,
2625 LayoutContextProvider,
2726 ParticipantTile,
2827 getTrackReferenceId,
2928 isTrackReference,
3029 useCreateLayoutContext,
30 useIsMuted,
3131 useLayoutContext,
3232 useMaybeTrackRefContext,
3333 useParticipants,
@@ -38,6 +38,7 @@
3838} from '@/shared/livekit'
3939import { useT } from '@/shared/i18n'
4040import { initials } from './voice-format'
41import { VoiceAvatarTile } from './voice-avatar-tile'
4142import type { VoiceTileLayout } from './voice-tile-layout'
4243
4344const VOLUME_PREFIX = 'gitdata.voice.volume.'
@@ -49,6 +50,8 @@
4950 layout: VoiceTileLayout
5051 /** Server-side moderator mutes, keyed by user id (identity === user id). */
5152 mutedIds: Set<string>
53 /** Avatar per user id, from the presence snapshot (identity === user id). */
54 avatarUrls: Map<string, string | null>
5255 onMute: (userId: string, muted: boolean) => void
5356 onKick: (userId: string) => void
5457}
@@ -168,9 +171,53 @@
168171}
169172
170173/**
174 * What a window shows: live video when there is any, and the member's avatar
175 * otherwise. "Live video" is a running camera *or* a screen share — a share must
176 * keep the official tile, since an avatar in its place would hide what is being
177 * presented.
178 *
179 * This is separate from `Window` because the choice is made by a hook and
180 * `Window` returns early when it has no track reference yet — a hook after that
181 * return would be called on some renders and skipped on others.
182 */
183function WindowMedia({
184 trackRef,
185 micRef,
186 avatarUrl,
187}: {
188 trackRef: TrackReferenceOrPlaceholder
189 micRef?: TrackReferenceOrPlaceholder
190 avatarUrl?: string | null
191}) {
192 /**
193 * A camera that is switched off is only *muted*: its publication keeps
194 * `source === 'camera'`. Judging by the source alone therefore kept the window
195 * on the official camera tile, which draws the generic grey placeholder — so a
196 * member looked like a missing person until they reconnected, and nothing ever
197 * brought the avatar back. The mute flag is what tells the two apart.
198 */
199 const cameraOff = useIsMuted(trackRef)
200 const showsVideo = isShare(trackRef) || (trackRef.source === Track.Source.Camera && !cameraOff)
201 if (showsVideo) {
202 return (
203 <>
204 <ParticipantTile trackRef={trackRef} className="voice-window-tile" />
205 {micRef ? (
206 /* The official tile only marks the source it renders, so a camera
207 window still needs this member's microphone state. */
208 <TrackMutedIndicator trackRef={micRef} show="muted" className="voice-window-mic" />
209 ) : null}
210 </>
211 )
212 }
213 return <VoiceAvatarTile trackRef={trackRef} micRef={micRef} avatarUrl={avatarUrl} />
214}
215
216/**
171217 * One window. Inside `GridLayout`/`CarouselLayout` the track reference comes
172218 * from the loop context; floated (placed) windows are rendered directly and get
173 * it as a property.
219 * it as a property. The window itself owns the frame and its gestures; what goes
220 * inside is `WindowMedia`.
174221 */
175222function Window({
176223 trackRef: explicitRef,
@@ -178,6 +225,7 @@
178225 viewerId,
179226 canModerate,
180227 mutedIds,
228 avatarUrls,
181229 onMute,
182230 onKick,
183231 layout,
@@ -205,7 +253,7 @@
205253 ]
206254 .filter(Boolean)
207255 .join(' ')}
208 style={box ? { left: box.left, top: box.top, width: box.width } : undefined}
256 style={box ? { left: box.left, top: box.top, width: box.width, height: box.height } : undefined}
209257 title={box ? t('chat.voice.tilePlaced') : t('chat.voice.tileHint')}
210258 onPointerDown={event => layout.beginGesture(event, identity, 'move')}
211259 onPointerMove={event => layout.moveGesture(event, identity)}
@@ -223,19 +271,11 @@
223271 layout.releaseTile(identity)
224272 }}
225273 >
226 {trackRef.source === Track.Source.Microphone ? (
227 /* The generic tile labels every non-camera source as a screen share, so
228 audio-only members get the official audio tile instead: level bars,
229 microphone state, name and quality. */
230 <ParticipantAudioTile trackRef={trackRef} className="voice-window-tile" />
231 ) : (
232 <ParticipantTile trackRef={trackRef} className="voice-window-tile" />
233 )}
234 {micRef && trackRef.source !== Track.Source.Microphone ? (
235 // The official tile only marks the source it renders, so a member whose
236 // window shows their camera still needs the microphone state.
237 <TrackMutedIndicator trackRef={micRef} show="muted" className="voice-window-mic" />
238 ) : null}
274 <WindowMedia
275 trackRef={trackRef}
276 micRef={micRef}
277 avatarUrl={avatarUrls.get(identity)}
278 />
239279 {box ? (
240280 <span
241281 className="voice-window-resize"
@@ -294,7 +334,7 @@
294334}
295335
296336export function VoiceTileBoard(props: BoardProps) {
297 const { roomId, viewerId, canModerate, layout, mutedIds, onMute, onKick } = props
337 const { roomId, viewerId, canModerate, layout, mutedIds, avatarUrls, onMute, onKick } = props
298338 const { t } = useT()
299339 const layoutContext = useCreateLayoutContext()
300340 const pinnedTracks = usePinnedTracks(layoutContext)
@@ -381,6 +421,7 @@
381421 viewerId,
382422 canModerate,
383423 mutedIds,
424 avatarUrls,
384425 onMute,
385426 onKick,
386427 micRefs,
Modified web/src/features/chat/ui/voice/voice.css web/src/features/chat/ui/voice/voice.css +74 −1 View web/src/features/chat/ui/voice/voice.css
@@ -432,6 +432,10 @@
432432 left: 12px;
433433 animation: none;
434434 }
435 /* 逐格跳动的窗口对前庭敏感的人更难读,直接给到最终位置。 */
436 .voice-window.is-floating:not(.is-dragging) {
437 transition: none;
438 }
435439}
436440
437441/* ---------------------------------------------- LiveKit 组件主题对齐 shell --
@@ -484,17 +488,85 @@
484488.voice-board .lk-video-track,
485489.voice-board .lk-audio-bar-visualizer { border-radius: 11px; }
486490
491/*
492 * 无摄像头成员:头像 + 随声音胀缩的光环。
493 *
494 * 窗口可以被拖动缩放(140px … 640px),所以尺寸一律用容器单位:写死像素会让
495 * 大窗口里的头像小得像个图标。光环不读 --lk-va-* 变量,因为那些是官方条形电平
496 * 表的尺寸;电平由 React 写进 --voice-level(0…1 的比例)。
497 */
498.voice-avatar-tile {
499 position: relative;
500 display: flex;
501 height: 100%;
502 align-items: center;
503 justify-content: center;
504 background: var(--shell-field);
505 container-type: inline-size;
506}
507.voice-avatar-ring {
508 position: absolute;
509 left: 50%;
510 top: 50%;
511 transform: translate(-50%, -50%);
512 width: calc(44cqw * (1 + var(--voice-level, 0)));
513 height: calc(44cqw * (1 + var(--voice-level, 0)));
514 border-radius: 50%;
515 border: max(1px, 1.2cqw) solid color-mix(in srgb, var(--shell-ink-muted) 45%, transparent);
516 opacity: .5;
517 transition: width 90ms linear, height 90ms linear, border-color 160ms, opacity 160ms;
518}
519.voice-avatar-tile[data-lk-speaking='true'] .voice-avatar-ring {
520 border-color: color-mix(in srgb, var(--success-foreground) 70%, transparent);
521 box-shadow: 0 0 0 6px color-mix(in srgb, var(--success-foreground) 18%, transparent);
522 opacity: .95;
523}
524.voice-avatar-face {
525 width: 40cqw;
526 height: 40cqw;
527 font-size: 15cqw;
528 box-shadow: 0 6px 18px rgb(24 28 31 / 12%);
529}
530.voice-avatar-name {
531 position: absolute;
532 bottom: 10px;
533 left: 50%;
534 display: flex;
535 max-width: calc(100% - 20px);
536 align-items: center;
537 gap: 4px;
538 padding: max(2px, .5cqw) max(7px, 2.4cqw);
539 border-radius: 999px;
540 background: color-mix(in srgb, var(--shell-surface) 88%, transparent);
541 color: var(--shell-ink);
542 font-size: clamp(11px, 3.2cqw, 15px);
543 transform: translateX(-50%);
544}
545
487546/* 参与者窗口:网格里的一个,或被摆位后浮出来的一个。 */
488547.voice-window { position: relative; min-height: 0; user-select: none; touch-action: none; }
489548.voice-window .lk-participant-tile { height: 100%; }
549/*
550 * 浮出来的窗口长宽都由内联样式给出(两个轴独立,可以只拉宽或只拉高),所以这里
551 * 不能再留 aspect-ratio:它会反过来把高度重新绑到宽度上,垂直拖动就没有效果。
552 * 网格里的窗口仍由官方布局决定尺寸。
553 */
490554.voice-window.is-floating {
491555 position: absolute;
492556 z-index: 2;
493 aspect-ratio: 3 / 4;
494557 cursor: grab;
495558 pointer-events: auto;
496559}
497560.voice-window.is-dragging { z-index: 3; cursor: grabbing; }
561/*
562 * 别人拖动时,窗口是按 REMOTE_APPLY_MS 的节拍跳格子的;补上这段过渡,两次应用
563 * 之间就是滑动过去的,看起来才是「移动」而不是「瞬移」。拖动中的窗口和摆位以外
564 * 的窗口不能有过渡,否则松手的落点会慢半拍、网格重排也会被拖泥带水。
565 */
566.voice-window.is-floating:not(.is-dragging) {
567 transition: left 460ms cubic-bezier(.22,.68,.28,1), top 460ms cubic-bezier(.22,.68,.28,1),
568 width 460ms cubic-bezier(.22,.68,.28,1), height 460ms cubic-bezier(.22,.68,.28,1);
569}
498570/* 摄像头窗口上的麦克风状态:官方指示器只标注它渲染的那一路。 */
499571.voice-call-quality {
500572 width: 14px;
@@ -661,3 +733,4 @@
661733 clip-path: inset(50%);
662734 white-space: nowrap;
663735}
736
Modified web/src/features/chat/ui/parts.tsx web/src/features/chat/ui/parts.tsx +8 −2 View web/src/features/chat/ui/parts.tsx
@@ -64,7 +64,9 @@
6464 loading="lazy"
6565 onError={() => setFailedUrl(src)}
6666 className={`shrink-0 rounded-full bg-shell-tile object-cover ${className}`}
67 style={{ width: size, height: size }}
67 /* Passing `className` hands the size to CSS (a resizable window cannot
68 use a fixed pixel avatar); `size` still covers every other caller. */
69 style={className ? undefined : { width: size, height: size }}
6870 />
6971 )
7072 }
@@ -72,7 +74,11 @@
7274 <span
7375 title={label}
7476 className={`grid shrink-0 place-content-center rounded-full font-semibold text-white ${className}`}
75 style={{ width: size, height: size, backgroundColor: colorFor(id || '0'), fontSize: size * 0.4 }}
77 style={
78 className
79 ? { backgroundColor: colorFor(id || '0') }
80 : { width: size, height: size, backgroundColor: colorFor(id || '0'), fontSize: size * 0.4 }
81 }
7682 >
7783 {initialsOf(user)}
7884 </span>
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 +1 −0 View web/src/shared/i18n/messages/en/chat.ts
@@ -277,6 +277,7 @@
277277 'chat.voice.forceUnmute': 'Unmute',
278278 'chat.voice.forceMute': 'Mute',
279279 'chat.voice.kick': 'Remove from the call',
280 'chat.voice.tileSpeaking': '{name} is speaking',
280281 'chat.voice.solo': 'Nobody is here with you yet — invite members to join.',
281282 'chat.voice.hands': 'Speaking requests',
282283 'chat.voice.canSpeak': 'You may speak now',
Modified web/src/shared/i18n/messages/zh-CN/chat.ts web/src/shared/i18n/messages/zh-CN/chat.ts +1 −0 View web/src/shared/i18n/messages/zh-CN/chat.ts
@@ -277,6 +277,7 @@
277277 'chat.voice.forceUnmute': '解除静音',
278278 'chat.voice.forceMute': '强制静音',
279279 'chat.voice.kick': '移出通话',
280 'chat.voice.tileSpeaking': '{name} 正在说话',
280281 'chat.voice.solo': '还没有其他人和你一起,邀请成员加入吧。',
281282 'chat.voice.hands': '发言申请',
282283 'chat.voice.canSpeak': '已获得发言权',
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.
Modified openapi.json openapi.json View openapi.json
Diff too large to display.