Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/modules.calls/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export { useScreenShareCleanup } from './useScreenShareCleanup';
export { useVideoBlur } from './useVideoBlur';
export { useParticipantJoinSync } from './useParticipantJoinSync';
export { useUmamiActivityHeartbeat } from './useUmamiActivityHeartbeat';
export { useSortedTracks } from './useSortedTracks';
9 changes: 6 additions & 3 deletions packages/modules.calls/src/hooks/useCompactNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -56,7 +59,7 @@ export const useCompactNavigation = () => {

return {
currentParticipant,
participants,
participants: sorted,
currentIndex: currentParticipantIndex,
totalParticipants,
canGoNext,
Expand Down
105 changes: 105 additions & 0 deletions packages/modules.calls/src/hooks/useSortedTracks.ts
Original file line number Diff line number Diff line change
@@ -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<Map<string, number>>(new Map());

const activeSpeakers = room?.activeSpeakers ?? [];

Check warning on line 39 in packages/modules.calls/src/hooks/useSortedTracks.ts

View workflow job for this annotation

GitHub Actions / main / lint

The 'activeSpeakers' logical expression could make the dependencies of useMemo Hook (at line 104) change on every render. To fix this, wrap the initialization of 'activeSpeakers' in its own useMemo() Hook

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<string, number>();
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<number | undefined>(
(acc, id) => acc ?? raisedHandMap.get(id),
undefined,
);
const bHandTs = bIds.reduce<number | undefined>(
(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]);
}
4 changes: 2 additions & 2 deletions packages/modules.calls/src/styles/grid.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
:root {
/* Высоты панелей */
--header-height: 64px;
--upbar-height: 60px;
--bottom-bar-height: 80px;
--upbar-height: 44px;
--bottom-bar-height: 68px;

/* Доступная высота для сетки */
--available-height: calc(
Expand Down
2 changes: 1 addition & 1 deletion packages/modules.calls/src/ui/Bottom/BottomBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export const BottomBar = ({ saveUserChoices = true }: ControlBarProps) => {

return (
<div className={cn('relative w-full', isChatOpen && 'invisible sm:visible')}>
<div className="flex w-full flex-row justify-between p-4">
<div className="flex w-full flex-row justify-between p-4 pt-1">
<div />
<div className="flex flex-row gap-4">
<div className="bg-gray-0 border-gray-10 flex h-[48px] w-[92px] items-center justify-center gap-1 rounded-[16px] border">
Expand Down
2 changes: 1 addition & 1 deletion packages/modules.calls/src/ui/Up/UpBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export const UpBar = () => {
}, [isFullScreen]);

return (
<div className={cn('flex w-full flex-row items-end px-4 pb-4', isFullScreen && 'pt-2')}>
<div className={cn('flex w-full flex-row items-end px-4 pb-1', isFullScreen && 'pt-2')}>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
Expand Down
7 changes: 5 additions & 2 deletions packages/modules.calls/src/ui/VideoGrid/VideoGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ParticipantTile } from '../Participant';
import { CarouselContainer, GridLayout } from './VideoGridLayout';
import { useCallStore } from '../../store/callStore';
import { useScreenShareCleanup } from '../../hooks/useScreenShareCleanup';
import { useSortedTracks } from '../../hooks/useSortedTracks';
import '../../styles/grid.css';

export const VideoGrid = ({ ...props }: VideoConferenceProps) => {
Expand All @@ -33,14 +34,16 @@ export const VideoGrid = ({ ...props }: VideoConferenceProps) => {
},
);

const sortedTracks = useSortedTracks(tracks);

const layoutContext = useCreateLayoutContext();

const screenShareTracks = tracks
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare);

const focusTrack = usePinnedTracks(layoutContext)?.[0];
const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack));
const carouselTracks = sortedTracks.filter((track) => !isEqualTrackRef(track, focusTrack));

// Проверяем условия для FocusLayout
const hasScreenShare = screenShareTracks.some((track) => track.publication.isSubscribed);
Expand Down Expand Up @@ -104,7 +107,7 @@ export const VideoGrid = ({ ...props }: VideoConferenceProps) => {
<div className="flex h-full w-full items-center justify-center">
{effectiveCarouselType === 'grid' ? (
<div className="h-full w-full min-w-0">
<GridLayout tracks={tracks}>
<GridLayout tracks={sortedTracks}>
<ParticipantTile
isFocusToggleDisable
style={{
Expand Down
Loading