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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
przechwycic (to pasek LiveKit), wiec ostrzezenie wisi, dopoki zgoda trwa.
|
||||||
- Pasek sterowania LiveKit pokazuje przyciski wedlug `canPublishSources`, wiec
|
- Pasek sterowania LiveKit pokazuje przyciski wedlug `canPublishSources`, wiec
|
||||||
uczestnik z samym ekranem widzi tylko przycisk ekranu.
|
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
|
### Moderacja wyrazow
|
||||||
|
|
||||||
|
|||||||
+19
@@ -90,6 +90,25 @@ public class SpeakRequestAdminController {
|
|||||||
return view;
|
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) {
|
public record OpenMicRequest(boolean openMic) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,73 @@ public class SpeakRequestService {
|
|||||||
return view;
|
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) {
|
private SpeakRequest require(Long webinarId, Long requestId) {
|
||||||
SpeakRequest request = speakRequestRepository.findById(requestId)
|
SpeakRequest request = speakRequestRepository.findById(requestId)
|
||||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono zgłoszenia"));
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono zgłoszenia"));
|
||||||
|
|||||||
@@ -273,17 +273,18 @@ public class RoomPresenceService {
|
|||||||
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
|
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
|
||||||
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
|
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
|
||||||
registration.isChatMuted(), registration.isRoomBanned(),
|
registration.isChatMuted(), registration.isRoomBanned(),
|
||||||
registration.isCameraAllowed(), registration.isScreenAllowed()));
|
registration.isCameraAllowed(), registration.isScreenAllowed(),
|
||||||
|
voiceOf(registration)));
|
||||||
} else if (speaker != null) {
|
} else if (speaker != null) {
|
||||||
result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER,
|
result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER,
|
||||||
null, speaker.getEmail(), joinedAt, audio, video, screen, false, false,
|
null, speaker.getEmail(), joinedAt, audio, video, screen, false, false,
|
||||||
true, true));
|
true, true, true));
|
||||||
} else {
|
} else {
|
||||||
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
|
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
|
||||||
String name = info.getName().isBlank() ? identity : info.getName();
|
String name = info.getName().isBlank() ? identity : info.getName();
|
||||||
result.add(new ParticipantView(identity, name, KIND_OTHER,
|
result.add(new ParticipantView(identity, name, KIND_OTHER,
|
||||||
null, null, joinedAt, audio, video, screen, false, false,
|
null, null, joinedAt, audio, video, screen, false, false,
|
||||||
false, false));
|
false, false, false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -302,11 +303,12 @@ public class RoomPresenceService {
|
|||||||
if (r != null) {
|
if (r != null) {
|
||||||
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
|
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
|
||||||
r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false,
|
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) {
|
} else if (s != null) {
|
||||||
view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER,
|
view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER,
|
||||||
null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false,
|
null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false,
|
||||||
true, true);
|
true, true, true);
|
||||||
} else {
|
} else {
|
||||||
continue;
|
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) {
|
private static String fullName(Registration r) {
|
||||||
return ((r.getFirstName() == null ? "" : r.getFirstName()) + " "
|
return ((r.getFirstName() == null ? "" : r.getFirstName()) + " "
|
||||||
+ (r.getLastName() == null ? "" : r.getLastName())).trim();
|
+ (r.getLastName() == null ? "" : r.getLastName())).trim();
|
||||||
@@ -382,7 +389,9 @@ public class RoomPresenceService {
|
|||||||
/** Indywidualna zgoda prowadzacego na kamere (prelegent zawsze true). */
|
/** Indywidualna zgoda prowadzacego na kamere (prelegent zawsze true). */
|
||||||
boolean cameraAllowed,
|
boolean cameraAllowed,
|
||||||
/** Indywidualna zgoda prowadzacego na ekran (prelegent zawsze true). */
|
/** 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) {
|
public record BannedView(Long registrationId, String name, String email) {
|
||||||
|
|||||||
@@ -120,7 +120,12 @@ export function useInteractions(
|
|||||||
* drugi klient STOMP oznaczalby drugie uwierzytelnienie i druga sesje
|
* drugi klient STOMP oznaczalby drugie uwierzytelnienie i druga sesje
|
||||||
* obecnosci, czyli uczestnika liczonego podwojnie.
|
* 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. */
|
/** Wolane, gdy prowadzacy przelaczy tryb otwartego mikrofonu. */
|
||||||
onOpenMicChange?: (enabled: boolean) => void,
|
onOpenMicChange?: (enabled: boolean) => void,
|
||||||
/** Wolane przy moderacji uczestnika (wyciszenie/blokada), tylko gdy dotyczy jego samego. */
|
/** Wolane przy moderacji uczestnika (wyciszenie/blokada), tylko gdy dotyczy jego samego. */
|
||||||
@@ -262,7 +267,10 @@ export function useInteractions(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
speakHandler.current?.(event.payload)
|
speakHandler.current?.(
|
||||||
|
event.payload,
|
||||||
|
event.payload?.registrationId === myRegistrationIdRef.current,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
client.subscribe('/user/queue/errors', (message: IMessage) => {
|
client.subscribe('/user/queue/errors', (message: IMessage) => {
|
||||||
|
|||||||
@@ -118,8 +118,11 @@ export function useSpeakRequest(token: string | undefined) {
|
|||||||
* `registrationId` jednoznacznie wskazuje uczestnika i nie zmienia sie miedzy
|
* `registrationId` jednoznacznie wskazuje uczestnika i nie zmienia sie miedzy
|
||||||
* kolejnymi zgloszeniami tej samej osoby.
|
* kolejnymi zgloszeniami tej samej osoby.
|
||||||
*/
|
*/
|
||||||
const apply = useCallback((incoming: SpeakRequestView) => {
|
const apply = useCallback((incoming: SpeakRequestView, mine = false) => {
|
||||||
setRequest((current) => {
|
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,
|
// Bez biezacego zgloszenia nie wiemy, ktory registrationId jest nasz,
|
||||||
// a temat jest wspolny dla calej sali - wiec cudzego zdarzenia nie
|
// a temat jest wspolny dla calej sali - wiec cudzego zdarzenia nie
|
||||||
// przyjmujemy (inaczej przyznanie glosu komus innemu wlaczyloby mikrofon
|
// przyjmujemy (inaczej przyznanie glosu komus innemu wlaczyloby mikrofon
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type Participant = {
|
|||||||
cameraAllowed: boolean
|
cameraAllowed: boolean
|
||||||
/** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */
|
/** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */
|
||||||
screenAllowed: boolean
|
screenAllowed: boolean
|
||||||
|
/** Przyznany glos, z kolejki albo bez zgloszenia. */
|
||||||
|
voiceGranted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Presence = {
|
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) {
|
async function unban(registrationId: number, name: string) {
|
||||||
setBusy(`ban-${registrationId}`)
|
setBusy(`ban-${registrationId}`)
|
||||||
try {
|
try {
|
||||||
@@ -304,6 +329,31 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
|
|||||||
egzekwuje osobno kazde zrodlo. Przy innych dostawcach
|
egzekwuje osobno kazde zrodlo. Przy innych dostawcach
|
||||||
przycisk obiecywalby cos, czego nikt nie pilnuje.
|
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' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === participant.identity}
|
||||||
|
onClick={() => void toggleVoice(participant)}
|
||||||
|
aria-pressed={participant.voiceGranted}
|
||||||
|
aria-label={
|
||||||
|
participant.voiceGranted
|
||||||
|
? `Odbierz głos: ${participant.name}`
|
||||||
|
: `Daj głos bez zgłoszenia: ${participant.name}`
|
||||||
|
}
|
||||||
|
title={participant.voiceGranted ? 'Odbierz głos' : 'Daj głos bez zgłoszenia'}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
|
||||||
|
participant.voiceGranted
|
||||||
|
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
|
||||||
|
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={participant.voiceGranted ? 'mic' : 'mic-off'} size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && (
|
{participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user