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' && (
<>