From 16017fbace1fb80b8e3a58c335c794d9f208e422 Mon Sep 17 00:00:00 2001 From: unknownproperty Date: Sat, 21 Feb 2026 20:16:14 +0300 Subject: [PATCH 1/2] feat(modules.calls): add participants sort by active --- packages/modules.calls/src/hooks/index.ts | 1 + .../src/hooks/useCompactNavigation.ts | 9 +- .../src/hooks/useSortedTracks.ts | 105 ++++++++++++++++++ .../modules.calls/src/ui/Bottom/BottomBar.tsx | 2 +- packages/modules.calls/src/ui/Up/UpBar.tsx | 2 +- .../src/ui/VideoGrid/VideoGrid.tsx | 7 +- 6 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 packages/modules.calls/src/hooks/useSortedTracks.ts diff --git a/packages/modules.calls/src/hooks/index.ts b/packages/modules.calls/src/hooks/index.ts index acc0ce1a..46c3728d 100644 --- a/packages/modules.calls/src/hooks/index.ts +++ b/packages/modules.calls/src/hooks/index.ts @@ -17,3 +17,4 @@ export { useScreenShareCleanup } from './useScreenShareCleanup'; export { useVideoBlur } from './useVideoBlur'; export { useParticipantJoinSync } from './useParticipantJoinSync'; export { useUmamiActivityHeartbeat } from './useUmamiActivityHeartbeat'; +export { useSortedTracks } from './useSortedTracks'; diff --git a/packages/modules.calls/src/hooks/useCompactNavigation.ts b/packages/modules.calls/src/hooks/useCompactNavigation.ts index cd040fd1..7fd25134 100644 --- a/packages/modules.calls/src/hooks/useCompactNavigation.ts +++ b/packages/modules.calls/src/hooks/useCompactNavigation.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Track } from 'livekit-client'; import { useTracks } from '@livekit/components-react'; import { useScreenShareCleanup } from './useScreenShareCleanup'; +import { useSortedTracks } from './useSortedTracks'; export const useCompactNavigation = () => { const [currentParticipantIndex, setCurrentParticipantIndex] = useState(0); @@ -20,8 +21,10 @@ export const useCompactNavigation = () => { // Автоматическое удаление треков демонстрации экрана при их завершении useScreenShareCleanup(participants); - const currentParticipant = participants[currentParticipantIndex] || null; - const totalParticipants = participants.length; + const sorted = useSortedTracks(participants); + + const currentParticipant = sorted[currentParticipantIndex] || null; + const totalParticipants = sorted.length; const canGoNext = currentParticipantIndex < totalParticipants - 1; const canGoPrev = currentParticipantIndex > 0; @@ -56,7 +59,7 @@ export const useCompactNavigation = () => { return { currentParticipant, - participants, + participants: sorted, currentIndex: currentParticipantIndex, totalParticipants, canGoNext, diff --git a/packages/modules.calls/src/hooks/useSortedTracks.ts b/packages/modules.calls/src/hooks/useSortedTracks.ts new file mode 100644 index 00000000..079bbdd7 --- /dev/null +++ b/packages/modules.calls/src/hooks/useSortedTracks.ts @@ -0,0 +1,105 @@ +import { useMemo, useRef } from 'react'; +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'; +import { Track } from 'livekit-client'; +import { useCallStore } from '../store/callStore'; +import { useRoom } from '../providers/RoomProvider'; + +const SPEAKER_STICKY_MS = 10_000; + +function getParticipantIds(participant: { identity: string; metadata?: string }): string[] { + const ids = [participant.identity]; + if (participant.metadata) { + try { + const meta = JSON.parse(participant.metadata); + if (meta?.user_id) ids.push(meta.user_id); + if (meta?.id) ids.push(meta.id); + } catch { + /* metadata is not JSON */ + } + } + return ids; +} + +/** + * Сортирует треки участников по приоритету: + * 1. Демонстрация экрана + * 2. Поднятая рука (более раннее время → выше) + * 3. Активно говорящий + * 4. Недавно говоривший (sticky-окно SPEAKER_STICKY_MS) + * 5. Остальные (стабильный порядок) + */ +export function useSortedTracks( + tracks: TrackReferenceOrPlaceholder[], +): TrackReferenceOrPlaceholder[] { + const raisedHands = useCallStore((state) => state.raisedHands); + const { room } = useRoom(); + + const recentSpeakersRef = useRef>(new Map()); + + const activeSpeakers = room?.activeSpeakers ?? []; + + const now = Date.now(); + for (const speaker of activeSpeakers) { + recentSpeakersRef.current.set(speaker.identity, now); + } + for (const [id, ts] of recentSpeakersRef.current) { + if (now - ts > SPEAKER_STICKY_MS) { + recentSpeakersRef.current.delete(id); + } + } + + return useMemo(() => { + if (tracks.length <= 1) return tracks; + + const activeSpeakerIds = new Set(activeSpeakers.map((s) => s.identity)); + + const raisedHandMap = new Map(); + for (const hand of raisedHands) { + raisedHandMap.set(hand.participantId, hand.timestamp); + } + + const recentSpeakers = recentSpeakersRef.current; + + const indexed = tracks.map((track, i) => ({ track, idx: i })); + + indexed.sort((a, b) => { + const aIsScreen = a.track.source === Track.Source.ScreenShare; + const bIsScreen = b.track.source === Track.Source.ScreenShare; + + if (aIsScreen !== bIsScreen) return aIsScreen ? -1 : 1; + + const aIds = getParticipantIds(a.track.participant); + const bIds = getParticipantIds(b.track.participant); + + const aHandTs = aIds.reduce( + (acc, id) => acc ?? raisedHandMap.get(id), + undefined, + ); + const bHandTs = bIds.reduce( + (acc, id) => acc ?? raisedHandMap.get(id), + undefined, + ); + + const aHasHand = aHandTs !== undefined; + const bHasHand = bHandTs !== undefined; + + if (aHasHand !== bHasHand) return aHasHand ? -1 : 1; + if (aHasHand && bHasHand) return aHandTs! - bHandTs!; + + const aIsSpeaking = activeSpeakerIds.has(a.track.participant.identity); + const bIsSpeaking = activeSpeakerIds.has(b.track.participant.identity); + + if (aIsSpeaking !== bIsSpeaking) return aIsSpeaking ? -1 : 1; + + const aRecent = recentSpeakers.has(a.track.participant.identity); + const bRecent = recentSpeakers.has(b.track.participant.identity); + + if (aRecent !== bRecent) return aRecent ? -1 : 1; + + return a.idx - b.idx; + }); + + return indexed.map(({ track }) => track); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tracks, raisedHands, activeSpeakers]); +} diff --git a/packages/modules.calls/src/ui/Bottom/BottomBar.tsx b/packages/modules.calls/src/ui/Bottom/BottomBar.tsx index b52f3512..2ed62ee9 100644 --- a/packages/modules.calls/src/ui/Bottom/BottomBar.tsx +++ b/packages/modules.calls/src/ui/Bottom/BottomBar.tsx @@ -102,7 +102,7 @@ export const BottomBar = ({ saveUserChoices = true }: ControlBarProps) => { return (
-
+
diff --git a/packages/modules.calls/src/ui/Up/UpBar.tsx b/packages/modules.calls/src/ui/Up/UpBar.tsx index d76dd85b..5a1559e9 100644 --- a/packages/modules.calls/src/ui/Up/UpBar.tsx +++ b/packages/modules.calls/src/ui/Up/UpBar.tsx @@ -100,7 +100,7 @@ export const UpBar = () => { }, [isFullScreen]); return ( -
+