feat(voice): implement shared layout management for participant windows with remote synchronization
ZhenYi committed
Modified web/src/entities/chat/model/voice-api.ts web/src/entities/chat/model/voice-api.ts +19 −0 View web/src/entities/chat/model/voice-api.ts
@@ -14,6 +14,7 @@
1414 AppResponseData_VoiceSpaceStatesDto,
1515 AppResponseData_VoiceTokenDto,
1616 HandState,
17 VoiceTileDto,
1718} from '@/shared/api'
1819
1920export type VoiceConfigDto = NonNullable<AppResponseData_VoiceConfigDto['data']>
@@ -23,6 +24,8 @@
2324export type VoiceHands = NonNullable<AppResponseData_VoiceHandsDto['data']>
2425export type VoiceModeration = NonNullable<AppResponseData_VoiceModerationDto['data']>
2526export type VoiceSpaceStates = NonNullable<AppResponseData_VoiceSpaceStatesDto['data']>
27export type VoiceTile = VoiceTileDto
28export type VoiceLayoutTiles = Record<string, VoiceTileDto>
2629export type VoiceHandState = HandState
2730
2831export function getVoiceConfig(): Promise<VoiceConfigDto> {
@@ -87,3 +90,19 @@
8790 Apis.chat.kick_participant({ pathParams: { room_id: roomId, user_id: userId } }),
8891 )
8992}
93
94/**
95 * Shared participant-window layout. Coordinates are normalized against the
96 * free canvas span, so the same layout lands inside the bounds on every screen.
97 */
98export function getVoiceLayout(roomId: string): Promise<VoiceLayoutTiles> {
99 return unwrapApi(
100 Apis.chat.get_voice_layout({ pathParams: { room_id: roomId } }),
101 ).then(data => data?.tiles ?? {})
102}
103
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)
108}
No newline at end of file
Modified web/src/entities/chat/index.ts web/src/entities/chat/index.ts +4 −0 View web/src/entities/chat/index.ts
@@ -12,6 +12,7 @@
1212 cancelVoiceHand,
1313 denyVoiceHand,
1414 getVoiceConfig,
15 getVoiceLayout,
1516 getVoiceSpaceStates,
1617 kickVoiceParticipant,
1718 listVoiceHands,
@@ -20,6 +21,7 @@
2021 raiseVoiceHand,
2122 requestVoiceToken,
2223 revokeVoiceHand,
24 saveVoiceLayout,
2325} from './model/voice-api'
2426export type {
2527 VoiceConfigDto,
@@ -29,5 +31,7 @@
2931 VoiceModeration,
3032 VoiceParticipant,
3133 VoiceSpaceStates,
34 VoiceLayoutTiles,
3235 VoiceToken,
36 VoiceTile,
3337} from './model/voice-api'
Modified web/src/entities/issue/model/meta.ts web/src/entities/issue/model/meta.ts +3 −6 View web/src/entities/issue/model/meta.ts
@@ -4,11 +4,8 @@
44 * component) because the kanban columns, the filter chips, the list table and
55 * the detail sidebar all have to agree on them.
66 */
7import { getLocale, t, type MessageKey } from '@/shared/i18n'
8import { zhCN } from '@/shared/i18n/messages/zh-CN'
7import { getLocale, isMessageKey, t } from '@/shared/i18n'
98import type { IssuePriority, IssueState, IssueSort, WatchMode } from './issue'
10
11const MESSAGE_KEYS: ReadonlySet<string> = new Set(Object.keys(zhCN))
129
1310export interface PriorityMeta {
1411 id: IssuePriority
@@ -174,8 +171,8 @@
174171 {},
175172 {
176173 get: (_target, key: string) => {
177 const entry = `issue.event.${key}` as MessageKey
178 return entry in MESSAGE_KEYS ? t(entry) : undefined
174 const entry = `issue.event.${key}`
175 return isMessageKey(entry) ? t(entry) : undefined
179176 },
180177 },
181178)
Modified web/src/features/chat/ui/voice/voice-room-panel.tsx web/src/features/chat/ui/voice/voice-room-panel.tsx +193 −42 View web/src/features/chat/ui/voice/voice-room-panel.tsx
@@ -31,13 +31,16 @@
3131 cancelVoiceHand,
3232 denyVoiceHand,
3333 getVoiceConfig,
34 getVoiceLayout,
3435 listVoiceHands,
3536 kickVoiceParticipant,
3637 moderateVoiceMute,
3738 raiseVoiceHand,
3839 requestVoiceToken,
40 saveVoiceLayout,
3941 revokeVoiceHand,
4042 type VoiceHands,
43 type VoiceLayoutTiles,
4144 type VoiceParticipant,
4245} from '@/entities/chat'
4346import { useVoiceSession, voiceSession, participantTracks } from '@/shared/voice'
@@ -86,26 +89,84 @@
8689 )
8790}
8891
89/** A participant window the user has parked somewhere on the canvas. */
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 */
9097interface TilePlacement {
98 /** 0 -> glued to the left edge, 1 -> glued to the right edge. */
9199 x: number
100 /** 0 -> glued to the top edge, 1 -> glued to the bottom edge. */
92101 y: number
102 /** 0 -> smallest window, 1 -> largest window. */
93103 w: number
94104}
95105
106interface TileBox {
107 left: number
108 top: number
109 width: number
110}
111
96112interface TileGesture {
97113 mode: 'move' | 'resize'
98114 key: string
99 origin: TilePlacement
115 origin: TileBox
100116 startX: number
101117 startY: number
102118}
103119
120interface CanvasBox {
121 width: number
122 height: number
123}
124
104125const LAYOUT_PREFIX = 'gitdata.voice.tiles.'
105126const TILE_MIN_WIDTH = 140
106127const TILE_MAX_WIDTH = 640
107128const TILE_SNAP = 4
108129const 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}
109170
110171function readLayout(roomId: string): Record<string, TilePlacement> {
111172 try {
@@ -116,10 +177,10 @@
116177 for (const [key, value] of Object.entries(parsed)) {
117178 if (
118179 typeof value?.x === 'number' &&
119 typeof value.y === 'number' &&
120 typeof value.w === 'number'
180 typeof value?.y === 'number' &&
181 typeof value?.w === 'number'
121182 ) {
122 out[key] = { x: value.x, y: value.y, w: value.w }
183 out[key] = { x: clamp01(value.x), y: clamp01(value.y), w: clamp01(value.w) }
123184 }
124185 }
125186 return out
@@ -138,6 +199,7 @@
138199 }
139200}
140201
202
141203const DANMAKU_KEY = 'gitdata.voice.danmaku'
142204
143205function readDanmakuPreference(): boolean {
@@ -186,46 +248,113 @@
186248 const [danmakuOn, setDanmakuOn] = useState(readDanmakuPreference)
187249 const [historyOpen, setHistoryOpen] = useState(false)
188250 const canvasRef = useRef<HTMLDivElement | null>(null)
189 const canvasBox = useRef({ width: 0, height: 0 })
190251 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)
191256 const [layout, setLayout] = useState<Record<string, TilePlacement>>(() => readLayout(roomId))
192257 const layoutRef = useRef(layout)
193258
194 // The canvas box is what keeps parked windows inside it after a resize.
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.
195261 useEffect(() => {
262 if (!joined) return
196263 const el = canvasRef.current
197264 if (!el) return
198 const measure = () => {
199 canvasBox.current = { width: el.clientWidth, height: el.clientHeight }
200 }
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 )
201271 measure()
202272 const observer = new ResizeObserver(measure)
203273 observer.observe(el)
204 return () => observer.disconnect()
205 }, [])
274 return () => {
275 observer.disconnect()
276 setCanvasSize({ width: 0, height: 0 })
277 }
278 }, [joined])
206279
207280 const commitLayout = (next: Record<string, TilePlacement>) => {
208281 layoutRef.current = next
209282 setLayout(next)
210283 }
211284
212 const clampPlacement = (placement: TilePlacement): TilePlacement => {
213 const w = Math.round(Math.min(Math.max(TILE_MIN_WIDTH, placement.w), TILE_MAX_WIDTH))
214 const { width, height } = canvasBox.current
215 if (width === 0 || height === 0) {
216 return { x: Math.round(placement.x), y: Math.round(placement.y), w }
217 }
218 return {
219 w,
220 x: Math.round(Math.min(Math.max(0, placement.x), Math.max(0, width - w))),
221 y: Math.round(
222 Math.min(Math.max(0, placement.y), Math.max(0, height - w * TILE_RATIO))
223 ),
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.
224310 }
225 }
311 }, [roomId, mergeRemote])
226312
227 const snap = (value: number) => Math.round(value / TILE_SNAP) * TILE_SNAP
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])
228346
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
229358 const beginTileGesture = (
230359 event: ReactPointerEvent<HTMLElement>,
231360 key: string,
@@ -240,17 +369,18 @@
240369 if (!el) return
241370 const canvasRect = canvas.getBoundingClientRect()
242371 const tileRect = el.getBoundingClientRect()
243 const origin =
244 layoutRef.current[key] ??
245 clampPlacement({
246 x: tileRect.left - canvasRect.left + canvas.scrollLeft,
247 y: tileRect.top - canvasRect.top + canvas.scrollTop,
248 w: tileRect.width,
249 })
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
250379 gestureRef.current = { mode, key, origin, startX: event.clientX, startY: event.clientY }
251380 // A grid window lifts out of the flow at exactly where it was drawn, so
252381 // the first pointer-down never makes it jump.
253 if (!layoutRef.current[key]) commitLayout({ ...layoutRef.current, [key]: origin })
382 if (!parked) commitLayout({ ...layoutRef.current, [key]: normalizedFor(canvasSize, rectBox) })
383 setDraggingKey(key)
254384 el.setPointerCapture(event.pointerId)
255385 event.preventDefault()
256386 }
@@ -260,26 +390,33 @@
260390 if (!active || active.key !== key) return
261391 const dx = event.clientX - active.startX
262392 const dy = event.clientY - active.startY
263 const next =
393 const next: TileBox =
264394 active.mode === 'move'
265 ? { x: snap(active.origin.x + dx), y: snap(active.origin.y + dy), w: active.origin.w }
266 : { ...active.origin, w: snap(active.origin.w + dx) }
267 commitLayout({ ...layoutRef.current, [key]: clampPlacement(next) })
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)
268403 }
269404
270405 const endTileGesture = (key: string) => {
271406 const active = gestureRef.current
272407 if (!active || active.key !== key) return
273408 gestureRef.current = null
274 writeLayout(roomId, layoutRef.current)
409 setDraggingKey(null)
410 pushLayout(true)
275411 }
276412
277413 const releaseTile = (key: string) => {
278414 const next = { ...layoutRef.current }
279415 delete next[key]
280416 commitLayout(next)
281 writeLayout(roomId, next)
417 pushLayout(true)
282418 }
419
283420 const room = voiceSession.getRoom()
284421 const trackByIdentity = useMemo(() => {
285422 const map = new Map<string, Track>()
@@ -351,6 +488,7 @@
351488 useWsFrames(frame => {
352489 if (!('room_id' in frame) || frame.room_id !== roomId) return
353490 if (frame.type === 'event' && frame.event?.kind === 'voice_hands') void refreshHands()
491 if (frame.type === 'event' && frame.event?.kind === 'voice_layout') void pullLayout()
354492 })
355493
356494 useEffect(() => {
@@ -437,6 +575,14 @@
437575 [participants, trackByIdentity, viewerId],
438576 )
439577
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])
585
440586 const myHand = hands?.my_state
441587 const pendingHands = (hands?.hands ?? []).filter(hand => hand.state === 'pending')
442588 const approvedHands = (hands?.hands ?? []).filter(hand => hand.state === 'approved')
@@ -524,7 +670,7 @@
524670 {tiles.map(tile => {
525671 const speaking = (tile.isSelf && session.speaking) || tile.speaking
526672 const parked = layout[tile.user_id]
527 const placed = parked ? clampPlacement(parked) : null
673 const placed = parked ? pixelsFor(canvasSize, parked) : null
528674 return (
529675 <div
530676 key={tile.user_id}
@@ -534,10 +680,15 @@
534680 'voice-tile',
535681 speaking ? 'is-speaking' : '',
536682 placed ? 'is-floating' : '',
683 draggingKey === tile.user_id ? 'is-dragging' : '',
537684 ]
538685 .filter(Boolean)
539686 .join(' ')}
540 style={placed ? { left: placed.x, top: placed.y, width: placed.w } : undefined}
687 style={
688 placed
689 ? { left: placed.left, top: placed.top, width: placed.width }
690 : undefined
691 }
541692 title={placed ? t('chat.voice.tilePlaced') : t('chat.voice.tileHint')}
542693 onPointerDown={event => beginTileGesture(event, tile.user_id, 'move')}
543694 onPointerMove={event => moveTileGesture(event, tile.user_id)}
Modified web/src/features/chat/ui/voice/voice.css web/src/features/chat/ui/voice/voice.css +10 −1 View web/src/features/chat/ui/voice/voice.css
@@ -133,6 +133,9 @@
133133 gap: 14px;
134134 padding: 18px 16px;
135135 overflow-y: auto;
136 /* Parked windows are absolutely placed against this box. */
137 position: relative;
138 overflow-x: hidden;
136139}
137140.voice-tile {
138141 position: relative;
@@ -202,7 +205,13 @@
202205.voice-tile { user-select: none; touch-action: none; cursor: grab; }
203206.voice-tile:active { cursor: grabbing; }
204207.voice-tile-actions button { cursor: pointer; }
205.voice-tile.is-floating { position: absolute; box-shadow: 0 12px 30px rgb(15 17 19 / 18%); }
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; }
206215.voice-tile.is-speaking, .voice-tile.is-floating.is-speaking {
207216 box-shadow:
208217 0 0 0 2px color-mix(in srgb, var(--primary) 26%, transparent),
Modified web/src/shared/i18n/index.ts web/src/shared/i18n/index.ts +1 −0 View web/src/shared/i18n/index.ts
@@ -12,6 +12,7 @@
1212 useLocale,
1313 type Locale,
1414} from './locale'
15export { isMessageKey } from './translate'
1516export { t, tCount, translate, translateCount, type MessageKey, type MessageParams } from './translate'
1617export { useT, type I18n, type TranslateFn, type TranslateCountFn } from './use-t'
1718export {
Modified web/src/shared/i18n/translate.ts web/src/shared/i18n/translate.ts +7 −0 View web/src/shared/i18n/translate.ts
@@ -11,6 +11,13 @@
1111
1212export type { MessageKey }
1313
14/** Membership test against the reference (zh-CN) dictionary. */
15const MESSAGE_KEYS: ReadonlySet<string> = new Set(Object.keys(zhCN))
16
17export function isMessageKey(key: string): key is MessageKey {
18 return MESSAGE_KEYS.has(key)
19}
20
1421/** Values accepted by `{placeholder}` interpolation. */
1522export type MessageParams = Record<string, string | number>
1623
Modified web/src/shared/ws/protocol.ts web/src/shared/ws/protocol.ts +2 −0 View web/src/shared/ws/protocol.ts
@@ -166,6 +166,7 @@
166166 'typing',
167167 'voice_presence',
168168 'voice_hands',
169 'voice_layout',
169170 'voice_moderation',
170171 'room_updated',
171172 'member_joined',
@@ -208,6 +209,7 @@
208209 }
209210 | { kind: 'voice_presence'; room_id: string }
210211 | { kind: 'voice_hands'; room_id: string }
212 | { kind: 'voice_layout'; room_id: string }
211213 | { kind: 'voice_moderation'; room_id: string; user_id: string; action: string }
212214 | { kind: 'room_updated'; room_id: string }
213215 | { kind: 'member_joined'; room_id: string; user_id: string }