From 8e20d5aeda8abe00a9a8a61a50133bf4b02a2a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?= Date: Tue, 29 Sep 2026 13:11:18 +0200 Subject: [PATCH] Glos bez zgloszenia z karty "Na sali" Przycisk mikrofonu przy kazdym uczestniku daje i odbiera glos bez jego prosby. Idzie przez kolejke zgloszen (SpeakRequestService.setVoice): powstaje od razu przyznane zgloszenie albo zostaje przyznane oczekujace, wiec kolejka, napis "Masz glos" i automatyczne wlaczenie mikrofonu zgadzaja sie ze stanem. Serwer pokoju zmienia uprawnienia od razu. Uczestnik bez wlasnego zgloszenia rozpoznaje zdarzenie jako swoje po numerze zapisu z sesji, zamiast czekac na odpytanie co 10 sekund. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++ .../SpeakRequestAdminController.java | 19 ++++++ .../interaction/SpeakRequestService.java | 67 +++++++++++++++++++ .../webinar/video/RoomPresenceService.java | 21 ++++-- frontend/src/lib/useInteractions.ts | 12 +++- frontend/src/lib/useSpeakRequest.ts | 5 +- .../src/pages/webinars/RoomParticipants.tsx | 50 ++++++++++++++ 7 files changed, 171 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0bde828..e9da1fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1713,6 +1713,12 @@ w karcie "Na sali", endpoint `POST /admin/webinars/{id}/room/participants/{rid}/ przechwycic (to pasek LiveKit), wiec ostrzezenie wisi, dopoki zgoda trwa. - Pasek sterowania LiveKit pokazuje przyciski wedlug `canPublishSources`, wiec uczestnik z samym ekranem widzi tylko przycisk ekranu. +- **Glos bez zgloszenia idzie PRZEZ KOLEJKE, nie przez sama role** + (`SpeakRequestService.setVoice`): powstaje od razu przyznane zgloszenie albo + zostaje przyznane oczekujace. Sama rola w bazie dawalaby mikrofon, ktorego + nie widac w kolejce, bez napisu "Masz glos" u uczestnika i bez + automatycznego wlaczenia mikrofonu. Uczestnik rozpoznaje zdarzenie jako + swoje po numerze zapisu z sesji (`mine`), bo lokalnie nie ma zgloszenia. ### Moderacja wyrazow diff --git a/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestAdminController.java b/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestAdminController.java index 558f3bb..589f4b9 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestAdminController.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestAdminController.java @@ -90,6 +90,25 @@ public class SpeakRequestAdminController { return view; } + /** + * Glos dla konkretnego uczestnika bez jego zgloszenia, z karty "Na sali". + * + * Po zatwierdzeniu transakcji serwer pokoju otwiera albo zamyka mikrofon + * od razu, tak samo jak przy przyznaniu z kolejki. + */ + @PostMapping("/participants/{registrationId}/voice") + @Operation(summary = "Danie albo odebranie glosu uczestnikowi bez zgloszenia") + public SpeakRequestView setVoice(@PathVariable Long id, + @PathVariable Long registrationId, + @RequestBody VoiceRequest request) { + SpeakRequestView view = speakRequests.setVoice(id, registrationId, request.value()); + permissionSync.syncParticipant(id, registrationId); + return view; + } + + public record VoiceRequest(boolean value) { + } + public record OpenMicRequest(boolean openMic) { } diff --git a/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestService.java b/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestService.java index 229a427..b3ea8b1 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestService.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestService.java @@ -244,6 +244,73 @@ public class SpeakRequestService { return view; } + /** + * Glos dany albo odebrany przez prowadzacego bez prosby uczestnika. + * + * NIE ustawia samej roli. Idzie przez zgloszenie, zeby kolejka w panelu, + * napis "Masz glos" u uczestnika i automatyczne wlaczenie mikrofonu + * mowily to samo: glos bez zgloszenia bylby widoczny wylacznie w bazie, + * a prowadzacy nie mialby go czym odebrac w kolejce. + * + * Dawanie: oczekujace zgloszenie zostaje przyznane, a gdy go nie ma, + * powstaje od razu przyznane. Odbieranie: tak jak "Odbierz" w kolejce, + * a rola wraca do VIEWER takze wtedy, gdy zgloszenia nie bylo. + * + * @return biezace zgloszenie uczestnika albo null po odebraniu bez zgloszenia + */ + @Transactional + public SpeakRequestView setVoice(Long webinarId, Long registrationId, boolean voice) { + Registration registration = registrationRepository.findById(registrationId) + .filter(r -> r.getWebinar().getId().equals(webinarId)) + .orElseThrow(() -> new NotFoundException("Nie znaleziono uczestnika tego webinaru")); + + var open = speakRequestRepository.findOpenByRegistration(registrationId); + + if (!voice) { + SpeakRequestView view = null; + if (open.isPresent()) { + SpeakRequest request = open.get(); + request.setStatus(request.getStatus() == SpeakRequestStatus.GRANTED + ? SpeakRequestStatus.REVOKED + : SpeakRequestStatus.DECLINED); + request.setResolvedAt(OffsetDateTime.now()); + speakRequestRepository.save(request); + view = SpeakRequestView.of(request); + } + setRole(registration, RegistrationRoomRole.VIEWER); + if (view != null) { + broadcaster.speakRequest(webinarId, "SPEAK_REVOKED", view); + } + return view; + } + + SpeakRequest request; + if (open.isPresent()) { + request = open.get(); + } else { + request = new SpeakRequest(); + request.setWebinar(registration.getWebinar()); + request.setRegistration(registration); + } + request.setStatus(SpeakRequestStatus.GRANTED); + request.setResolvedAt(OffsetDateTime.now()); + try { + request = speakRequestRepository.saveAndFlush(request); + } catch (DataIntegrityViolationException e) { + // uczestnik zglosil sie w tej samej chwili: unikalny indeks odrzucil + // drugie otwarte zgloszenie. Ponowne klikniecie znajdzie juz jego + // zgloszenie i je przyzna, wiec wystarczy poprosic o powtorzenie + throw new ValidationException("Uczestnik właśnie zgłosił się do głosu. Spróbuj ponownie."); + } + setRole(registration, RegistrationRoomRole.SPEAKER); + + log.info("Prowadzacy dal glos bez zgloszenia zapisowi {} na webinarze {}", registrationId, webinarId); + + SpeakRequestView view = SpeakRequestView.of(request); + broadcaster.speakRequest(webinarId, "SPEAK_GRANTED", view); + return view; + } + private SpeakRequest require(Long webinarId, Long requestId) { SpeakRequest request = speakRequestRepository.findById(requestId) .orElseThrow(() -> new NotFoundException("Nie znaleziono zgłoszenia")); diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java index eaa5068..8b11a1e 100644 --- a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java +++ b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java @@ -273,17 +273,18 @@ public class RoomPresenceService { result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT, registration.getId(), registration.getEmail(), joinedAt, audio, video, screen, registration.isChatMuted(), registration.isRoomBanned(), - registration.isCameraAllowed(), registration.isScreenAllowed())); + registration.isCameraAllowed(), registration.isScreenAllowed(), + voiceOf(registration))); } else if (speaker != null) { result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER, null, speaker.getEmail(), joinedAt, audio, video, screen, false, false, - true, true)); + true, true, true)); } else { // polaczenie spoza naszej sciezki wejscia: nie ma go w bazie String name = info.getName().isBlank() ? identity : info.getName(); result.add(new ParticipantView(identity, name, KIND_OTHER, null, null, joinedAt, audio, video, screen, false, false, - false, false)); + false, false, false)); } } return result; @@ -302,11 +303,12 @@ public class RoomPresenceService { if (r != null) { view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT, r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false, - r.isChatMuted(), r.isRoomBanned(), r.isCameraAllowed(), r.isScreenAllowed()); + r.isChatMuted(), r.isRoomBanned(), r.isCameraAllowed(), r.isScreenAllowed(), + voiceOf(r)); } else if (s != null) { view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER, null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false, - true, true); + true, true, true); } else { continue; } @@ -350,6 +352,11 @@ public class RoomPresenceService { } } + /** Glos przyznany indywidualnie. Otwarty mikrofon calej sali sie tu nie liczy. */ + private static boolean voiceOf(Registration r) { + return r.getRoomRole() == com.softspm.webinar.domain.RegistrationRoomRole.SPEAKER; + } + private static String fullName(Registration r) { return ((r.getFirstName() == null ? "" : r.getFirstName()) + " " + (r.getLastName() == null ? "" : r.getLastName())).trim(); @@ -382,7 +389,9 @@ public class RoomPresenceService { /** Indywidualna zgoda prowadzacego na kamere (prelegent zawsze true). */ boolean cameraAllowed, /** Indywidualna zgoda prowadzacego na ekran (prelegent zawsze true). */ - boolean screenAllowed) { + boolean screenAllowed, + /** Przyznany glos: z kolejki albo bez zgloszenia (prelegent zawsze true). */ + boolean voiceGranted) { } public record BannedView(Long registrationId, String name, String email) { diff --git a/frontend/src/lib/useInteractions.ts b/frontend/src/lib/useInteractions.ts index c969a40..94076d0 100644 --- a/frontend/src/lib/useInteractions.ts +++ b/frontend/src/lib/useInteractions.ts @@ -120,7 +120,12 @@ export function useInteractions( * drugi klient STOMP oznaczalby drugie uwierzytelnienie i druga sesje * obecnosci, czyli uczestnika liczonego podwojnie. */ - onSpeakEvent?: (payload: SpeakEventPayload) => void, + /** + * `mine` mowi, czy zdarzenie dotyczy tego uczestnika. Bez tego uczestnik, + * ktoremu prowadzacy dal glos bez zgloszenia, nie rozpoznalby zdarzenia jako + * swojego i dowiedzialby sie o glosie dopiero przy odpytaniu co 10 sekund. + */ + onSpeakEvent?: (payload: SpeakEventPayload, mine: boolean) => void, /** Wolane, gdy prowadzacy przelaczy tryb otwartego mikrofonu. */ onOpenMicChange?: (enabled: boolean) => void, /** Wolane przy moderacji uczestnika (wyciszenie/blokada), tylko gdy dotyczy jego samego. */ @@ -262,7 +267,10 @@ export function useInteractions( } return } - speakHandler.current?.(event.payload) + speakHandler.current?.( + event.payload, + event.payload?.registrationId === myRegistrationIdRef.current, + ) }) client.subscribe('/user/queue/errors', (message: IMessage) => { diff --git a/frontend/src/lib/useSpeakRequest.ts b/frontend/src/lib/useSpeakRequest.ts index 0bc8584..9dbbe34 100644 --- a/frontend/src/lib/useSpeakRequest.ts +++ b/frontend/src/lib/useSpeakRequest.ts @@ -118,8 +118,11 @@ export function useSpeakRequest(token: string | undefined) { * `registrationId` jednoznacznie wskazuje uczestnika i nie zmienia sie miedzy * kolejnymi zgloszeniami tej samej osoby. */ - const apply = useCallback((incoming: SpeakRequestView) => { + const apply = useCallback((incoming: SpeakRequestView, mine = false) => { setRequest((current) => { + // glos dany przez prowadzacego bez zgloszenia: lokalnie nic jeszcze nie + // ma, ale zdarzenie jest nasze (`mine` sprawdza numer zapisu z sesji) + if (!current && mine) return incoming // Bez biezacego zgloszenia nie wiemy, ktory registrationId jest nasz, // a temat jest wspolny dla calej sali - wiec cudzego zdarzenia nie // przyjmujemy (inaczej przyznanie glosu komus innemu wlaczyloby mikrofon diff --git a/frontend/src/pages/webinars/RoomParticipants.tsx b/frontend/src/pages/webinars/RoomParticipants.tsx index e9aeaea..982bd62 100644 --- a/frontend/src/pages/webinars/RoomParticipants.tsx +++ b/frontend/src/pages/webinars/RoomParticipants.tsx @@ -25,6 +25,8 @@ type Participant = { cameraAllowed: boolean /** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */ screenAllowed: boolean + /** Przyznany glos, z kolejki albo bez zgloszenia. */ + voiceGranted: boolean } type Presence = { @@ -179,6 +181,29 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t } } + /** + * Glos bez zgloszenia. Idzie przez kolejke zgloszen, wiec uczestnik widzi + * "Masz glos", mikrofon wlacza mu sie sam, a prowadzacy moze go odebrac + * takze z karty "Zgloszenia do glosu". + */ + async function toggleVoice(participant: Participant) { + if (participant.registrationId === null) return + const next = !participant.voiceGranted + setBusy(participant.identity) + try { + await client.post( + `/admin/webinars/${webinarId}/speak-requests/participants/${participant.registrationId}/voice`, + { value: next }, + ) + toasts.success(next ? `${participant.name} ma głos` : `Odebrano głos: ${participant.name}`) + await refresh() + } catch (error) { + toasts.error(errorMessage(error, 'Nie udało się zmienić głosu')) + } finally { + setBusy(null) + } + } + async function unban(registrationId: number, name: string) { setBusy(`ban-${registrationId}`) try { @@ -304,6 +329,31 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t egzekwuje osobno kazde zrodlo. Przy innych dostawcach przycisk obiecywalby cos, czego nikt nie pilnuje. */} + {/* + Glos dziala u kazdego dostawcy, bo to ta sama droga co + przyznanie z kolejki. Kamera i ekran tylko przy LiveKit. + */} + {participant.kind === 'PARTICIPANT' && ( + + )} {participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && ( <>