Nagrywanie spotkania: przycisk w panelu i domykanie statusu
Deploy PROD / deploy (push) Successful in 26s
Deploy PROD / deploy (push) Successful in 26s
Nagrywania NIE DALO SIE URUCHOMIC. Backend mial `startRecording` i `stopRecording`, Egress byl skonfigurowany i pisal prosto do MinIO, ale brakowalo dwoch ogniw: przycisku w panelu i domkniecia stanu po zakonczeniu. `completeRecording` nie bylo wolane z ZADNEGO miejsca w kodzie. PRZYCISK W PANELU PROWADZENIA Obok trybow mikrofonu i wideo, bo tam prowadzacy podejmuje decyzje o sali. Osobno od nich, bo nagrywanie nie dotyczy uprawnien uczestnikow: to decyzja organizatora o utrwaleniu szkolenia. Start bez potwierdzenia (nagrywanie niczego nie ujawnia i da sie je zatrzymac), stop z potwierdzeniem, bo tego fragmentu nie nagra sie drugi raz. Stan czytany jest przy wejsciu z listy nagran, nie tylko trzymany lokalnie: prowadzacy moze odswiezyc strone albo wrocic z innego urzadzenia i musi zobaczyc, ze nagranie trwa, zamiast zaczynac drugie rownolegle. Sluzy do tego `providerRecordingId` dopisane do `RecordingView`. DOMYKANIE PRZEZ ODPYTYWANIE, NIE WEBHOOK LiveKit podpisuje webhooki tokenem JWT, ktorego `VideoWebhookController` nie rozpoznaje: przyjmuje wylacznie wspolny sekret w naglowku, tak jak Daily. Odpytywanie jest przy tym ODPORNIEJSZE: zgloszenie, ktore nie dojdzie (restart backendu w zlej chwili, awaria sieci), zostawia wpis w stanie DOWNLOADING na zawsze, a zadanie w tle samo to nadrabia. Opoznienie do minuty nie ma znaczenia, bo montaz pliku po stronie Egress i tak trwa dluzej. EGRESS_COMPLETE domyka wpis rozmiarem i czasem trwania, FAILED/ABORTED/ LIMIT_REACHED oznacza go jako nieudany Z POWODEM: wpis zostaje w bazie, zeby administrator widzial, ze proba byla i czym sie skonczyla. Zadanie nie rusza, gdy dostawca nie jest LiveKit, i nie oznacza nagrania jako nieudanego, gdy Egress zwyczajnie nie zna juz zadania: brak informacji to nie to samo co blad. `reconcileRecordings` jest nietransakcyjne (wola serwer po sieci) i domyka kazdy wpis przez `@Lazy self`: samowywolanie omija proxy Springa, a wtedy zapis odbywa sie bez transakcji i stan nagrania nie zmienia sie wcale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,15 @@ public record RecordingView(
|
|||||||
/** Adres do odtwarzania. Null, dopoki nagranie nie jest gotowe. */
|
/** Adres do odtwarzania. Null, dopoki nagranie nie jest gotowe. */
|
||||||
String url,
|
String url,
|
||||||
String error,
|
String error,
|
||||||
|
/**
|
||||||
|
* Identyfikator zadania u dostawcy.
|
||||||
|
*
|
||||||
|
* Panel prowadzenia rozpoznaje po nim TRWAJACE nagranie: bez tego
|
||||||
|
* prowadzacy po odswiezeniu strony widzialby przycisk "Nagraj
|
||||||
|
* spotkanie" przy nagrywaniu juz w toku i zaczynalby drugie.
|
||||||
|
* Widok jest administracyjny, a sam identyfikator niczego nie otwiera.
|
||||||
|
*/
|
||||||
|
String providerRecordingId,
|
||||||
OffsetDateTime createdAt) {
|
OffsetDateTime createdAt) {
|
||||||
|
|
||||||
public static RecordingView of(Recording recording) {
|
public static RecordingView of(Recording recording) {
|
||||||
@@ -41,6 +50,7 @@ public record RecordingView(
|
|||||||
recording.getContentType(),
|
recording.getContentType(),
|
||||||
recording.isPlayable() ? "/api/public/recordings/" + recording.getId() : null,
|
recording.isPlayable() ? "/api/public/recordings/" + recording.getId() : null,
|
||||||
recording.getError(),
|
recording.getError(),
|
||||||
|
recording.getProviderRecordingId(),
|
||||||
recording.getCreatedAt());
|
recording.getCreatedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ public interface RecordingRepository extends JpaRepository<Recording, Long> {
|
|||||||
|
|
||||||
List<Recording> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RecordingStatus status);
|
List<Recording> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RecordingStatus status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nagrania w danym stanie z CALEJ instancji.
|
||||||
|
*
|
||||||
|
* Uzywane przez zadanie domykajace nagrania Egress: pyta o wpisy wciaz
|
||||||
|
* w stanie DOWNLOADING, bez wiedzy, do ktorego webinaru naleza.
|
||||||
|
*/
|
||||||
|
List<Recording> findByStatus(RecordingStatus status);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nagrania gotowe do pokazania uczestnikowi.
|
* Nagrania gotowe do pokazania uczestnikowi.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ import livekit.LivekitEgress;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nagrywanie LiveKit przez komponent Egress.
|
* Nagrywanie LiveKit przez komponent Egress.
|
||||||
@@ -60,6 +64,17 @@ public class LiveKitEgressService {
|
|||||||
private final String minioSecretKey;
|
private final String minioSecretKey;
|
||||||
private final String minioBucket;
|
private final String minioBucket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referencja na samego siebie, przez proxy Springa.
|
||||||
|
*
|
||||||
|
* `reconcileRecordings` jest NIETRANSAKCYJNE (wola serwer po sieci), wiec
|
||||||
|
* wywolanie `completeRecording` przez `this` omijaloby proxy i zapis
|
||||||
|
* odbywalby sie bez transakcji: stan nagrania nie zmienialby sie wcale.
|
||||||
|
* Ten sam blad zdarzyl sie juz w tym projekcie trzy razy i pilnuje go
|
||||||
|
* `TransactionalSelfInvocationTest`.
|
||||||
|
*/
|
||||||
|
private final LiveKitEgressService self;
|
||||||
|
|
||||||
public LiveKitEgressService(SettingsService settings,
|
public LiveKitEgressService(SettingsService settings,
|
||||||
WebinarRepository webinarRepository,
|
WebinarRepository webinarRepository,
|
||||||
RecordingRepository recordingRepository,
|
RecordingRepository recordingRepository,
|
||||||
@@ -67,7 +82,8 @@ public class LiveKitEgressService {
|
|||||||
@Value("${app.minio.endpoint}") String minioEndpoint,
|
@Value("${app.minio.endpoint}") String minioEndpoint,
|
||||||
@Value("${app.minio.access-key}") String minioAccessKey,
|
@Value("${app.minio.access-key}") String minioAccessKey,
|
||||||
@Value("${app.minio.secret-key}") String minioSecretKey,
|
@Value("${app.minio.secret-key}") String minioSecretKey,
|
||||||
@Value("${app.minio.bucket}") String minioBucket) {
|
@Value("${app.minio.bucket}") String minioBucket,
|
||||||
|
@Lazy LiveKitEgressService self) {
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.webinarRepository = webinarRepository;
|
this.webinarRepository = webinarRepository;
|
||||||
this.recordingRepository = recordingRepository;
|
this.recordingRepository = recordingRepository;
|
||||||
@@ -76,6 +92,7 @@ public class LiveKitEgressService {
|
|||||||
this.minioAccessKey = minioAccessKey;
|
this.minioAccessKey = minioAccessKey;
|
||||||
this.minioSecretKey = minioSecretKey;
|
this.minioSecretKey = minioSecretKey;
|
||||||
this.minioBucket = minioBucket;
|
this.minioBucket = minioBucket;
|
||||||
|
this.self = self;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,6 +244,111 @@ public class LiveKitEgressService {
|
|||||||
}, () -> log.warn("Webhook Egress {} bez pasujacego wpisu w recordings", egressId));
|
}, () -> log.warn("Webhook Egress {} bez pasujacego wpisu w recordings", egressId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domyka nagrania, ktore Egress juz skonczyl.
|
||||||
|
*
|
||||||
|
* DLACZEGO ODPYTYWANIE, A NIE WEBHOOK
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
* LiveKit potrafi zglosic koniec nagrania webhookiem, ale podpisuje go
|
||||||
|
* tokenem JWT, ktorego `VideoWebhookController` nie rozpoznaje: przyjmuje
|
||||||
|
* wylacznie wspolny sekret w naglowku, tak jak Daily. Doklodanie tam
|
||||||
|
* drugiego mechanizmu uwierzytelniania to osobna praca.
|
||||||
|
*
|
||||||
|
* Odpytywanie jest przy tym ODPORNIEJSZE: webhook, ktory nie dojdzie
|
||||||
|
* (restart backendu w zlej chwili, chwilowa awaria sieci), zostawia wpis
|
||||||
|
* w stanie DOWNLOADING na zawsze. Zadanie w tle samo to nadrabia przy
|
||||||
|
* nastepnym przebiegu.
|
||||||
|
*
|
||||||
|
* Opoznienie do minuty nie ma znaczenia: montaz pliku po stronie Egress
|
||||||
|
* i tak trwa dluzej.
|
||||||
|
*
|
||||||
|
* Metoda jest publiczna i **nietransakcyjna**: wolanie sieciowe trwa,
|
||||||
|
* a kazdy wpis domykany jest osobno przez `completeRecording`, ktore ma
|
||||||
|
* wlasna transakcje. Trzymanie jednej transakcji przez cale odpytywanie
|
||||||
|
* blokowaloby polaczenie na czas odpowiedzi serwera.
|
||||||
|
*/
|
||||||
|
public void reconcileRecordings() {
|
||||||
|
List<Recording> pending = recordingRepository.findByStatus(RecordingStatus.DOWNLOADING);
|
||||||
|
if (pending.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, LivekitEgress.EgressInfo> byId;
|
||||||
|
try {
|
||||||
|
var response = egressClient().listEgress().execute();
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
log.warn("Nie udalo sie pobrac stanu Egress: {}", describe(response.code()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byId = response.body().stream()
|
||||||
|
.collect(Collectors.toMap(LivekitEgress.EgressInfo::getEgressId,
|
||||||
|
info -> info, (a, b) -> a));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// brak polaczenia nie jest bledem trwalym: sprobujemy za minute
|
||||||
|
log.warn("Nie udalo sie pobrac stanu Egress: {}", e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Recording recording : pending) {
|
||||||
|
String egressId = recording.getProviderRecordingId();
|
||||||
|
if (egressId == null || egressId.isBlank()) {
|
||||||
|
// nagranie wgrane recznie, nie ma czego pytac u dostawcy
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
LivekitEgress.EgressInfo info = byId.get(egressId);
|
||||||
|
if (info == null) {
|
||||||
|
/*
|
||||||
|
* Egress nie zna juz tego zadania. Historia jest u niego
|
||||||
|
* ograniczona, wiec po dluzszej przerwie wpis po prostu
|
||||||
|
* znika: zostawiamy go bez zmian, zeby administrator mogl
|
||||||
|
* zdecydowac sam, zamiast oznaczac nagranie jako nieudane
|
||||||
|
* na podstawie braku informacji.
|
||||||
|
*/
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (info.getStatus()) {
|
||||||
|
case EGRESS_COMPLETE -> {
|
||||||
|
long size = info.getFileResultsList().stream()
|
||||||
|
.mapToLong(LivekitEgress.FileInfo::getSize)
|
||||||
|
.max()
|
||||||
|
.orElse(0L);
|
||||||
|
// Egress podaje czas trwania w nanosekundach
|
||||||
|
long nanos = info.getFileResultsList().stream()
|
||||||
|
.mapToLong(LivekitEgress.FileInfo::getDuration)
|
||||||
|
.max()
|
||||||
|
.orElse(0L);
|
||||||
|
// zero oznacza "nie wiem", a nie nagranie zerowej dlugosci
|
||||||
|
Integer seconds = nanos > 0 ? (int) (nanos / 1_000_000_000L) : null;
|
||||||
|
self.completeRecording(egressId, size, seconds);
|
||||||
|
}
|
||||||
|
case EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED ->
|
||||||
|
self.failRecording(egressId, info.getStatus().name());
|
||||||
|
default -> {
|
||||||
|
// STARTING, ACTIVE, ENDING: nagranie wciaz trwa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oznacza nagranie jako nieudane.
|
||||||
|
*
|
||||||
|
* Wpis ZOSTAJE w bazie, zamiast zniknac: administrator ma zobaczyc, ze
|
||||||
|
* proba nagrania byla i czym sie skonczyla, zamiast szukac nagrania,
|
||||||
|
* ktorego nie ma i o ktorym nic nie wiadomo.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void failRecording(String egressId, String reason) {
|
||||||
|
recordingRepository.findByProviderRecordingId(egressId).ifPresent(recording -> {
|
||||||
|
recording.setStatus(RecordingStatus.FAILED);
|
||||||
|
recording.setError("Egress zakonczyl nagranie ze statusem " + reason);
|
||||||
|
recordingRepository.save(recording);
|
||||||
|
log.warn("Nagranie {} nieudane: {}", egressId, reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private EgressServiceClient egressClient() {
|
private EgressServiceClient egressClient() {
|
||||||
return EgressServiceClient.createClient(
|
return EgressServiceClient.createClient(
|
||||||
httpUrl(apiUrl()),
|
httpUrl(apiUrl()),
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.softspm.webinar.video;
|
||||||
|
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domykanie nagran zakonczonych przez Egress.
|
||||||
|
*
|
||||||
|
* DLACZEGO ODPYTYWANIE, A NIE WEBHOOK
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* LiveKit zglasza koniec nagrania webhookiem podpisanym tokenem JWT, ktorego
|
||||||
|
* `VideoWebhookController` nie rozpoznaje: przyjmuje wylacznie wspolny sekret
|
||||||
|
* w naglowku, tak jak Daily. Bez tego wpis w `recordings` zostawal w stanie
|
||||||
|
* DOWNLOADING **na zawsze**, a nagranie nie pojawialo sie ani w panelu, ani
|
||||||
|
* u uczestnikow, chociaz plik lezal juz w MinIO.
|
||||||
|
*
|
||||||
|
* Odpytywanie jest przy tym odporniejsze niz webhook: zgloszenie, ktore nie
|
||||||
|
* dojdzie (restart backendu w zlej chwili, chwilowa awaria sieci), zostawia
|
||||||
|
* nagranie w limbo bez zadnego sygnalu. Zadanie w tle samo to nadrabia.
|
||||||
|
*
|
||||||
|
* Opoznienie do minuty nie ma znaczenia, bo montaz pliku po stronie Egress
|
||||||
|
* i tak trwa dluzej.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RecordingScheduler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RecordingScheduler.class);
|
||||||
|
|
||||||
|
private final LiveKitEgressService egressService;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public RecordingScheduler(LiveKitEgressService egressService, SettingsService settings) {
|
||||||
|
this.egressService = egressService;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${RECORDING_RECONCILE_INTERVAL_MS:60000}")
|
||||||
|
public void reconcile() {
|
||||||
|
/*
|
||||||
|
* Instancja bez LiveKit nie ma czego odpytywac, a proba polaczenia
|
||||||
|
* konczylaby sie ostrzezeniem w logu co minute. Sprawdzamy ustawienie
|
||||||
|
* przy kazdym przebiegu, bo dostawce zmienia sie w panelu, bez restartu.
|
||||||
|
*/
|
||||||
|
if (!"LIVEKIT".equalsIgnoreCase(settings.get(SettingKey.VIDEO_PROVIDER))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
egressService.reconcileRecordings();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// blad odpytywania nie moze zatrzymac zadania na stale
|
||||||
|
log.error("Nie udalo sie sprawdzic stanu nagran: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -322,6 +322,8 @@ export type Recording = {
|
|||||||
/** Adres odtwarzania. Null, dopoki nagranie nie jest gotowe. */
|
/** Adres odtwarzania. Null, dopoki nagranie nie jest gotowe. */
|
||||||
url: string | null
|
url: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
|
/** Identyfikator zadania u dostawcy. Panel rozpoznaje po nim nagranie w toku. */
|
||||||
|
providerRecordingId: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,75 @@ export function SpeakQueue({
|
|||||||
// okno potwierdzenia wlaczenia otwartego mikrofonu (zamiast window.confirm)
|
// okno potwierdzenia wlaczenia otwartego mikrofonu (zamiast window.confirm)
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nagrywanie. `null` oznacza "nie nagrywamy", a wartosc to identyfikator
|
||||||
|
* zadania Egress, ktory trzeba oddac przy zatrzymaniu.
|
||||||
|
*
|
||||||
|
* Stan pobierany jest przy wejsciu z listy nagran, a nie tylko trzymany
|
||||||
|
* lokalnie: prowadzacy moze odswiezyc strone albo wrocic z innego urzadzenia
|
||||||
|
* i musi zobaczyc, ze nagranie trwa, zamiast zaczynac drugie rownolegle.
|
||||||
|
*/
|
||||||
|
const [egressId, setEgressId] = useState<string | null>(null)
|
||||||
|
const [recordingBusy, setRecordingBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await client.get<{ id: number; status: string; providerRecordingId: string | null }[]>(
|
||||||
|
`/admin/webinars/${webinarId}/recordings`,
|
||||||
|
)
|
||||||
|
if (!active) return
|
||||||
|
// DOWNLOADING oznacza "plik powstaje", czyli nagranie w toku
|
||||||
|
const running = response.data.find(
|
||||||
|
(r) => r.status === 'DOWNLOADING' && r.providerRecordingId,
|
||||||
|
)
|
||||||
|
setEgressId(running?.providerRecordingId ?? null)
|
||||||
|
} catch {
|
||||||
|
// brak listy nie moze zablokowac panelu prowadzenia
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [webinarId])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wlaczenie i zatrzymanie nagrywania.
|
||||||
|
*
|
||||||
|
* Bez potwierdzenia przy starcie: nagrywanie niczego nie ujawnia uczestnikom
|
||||||
|
* i da sie je zatrzymac w kazdej chwili. Przy zatrzymaniu pytamy, bo drugi
|
||||||
|
* raz tego fragmentu sie nie nagra.
|
||||||
|
*/
|
||||||
|
async function toggleRecording() {
|
||||||
|
if (recordingBusy) return
|
||||||
|
|
||||||
|
if (egressId && !confirm('Zatrzymać nagrywanie? Tego fragmentu nie nagrasz ponownie.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecordingBusy(true)
|
||||||
|
try {
|
||||||
|
if (egressId) {
|
||||||
|
await client.post(
|
||||||
|
`/admin/webinars/${webinarId}/recording/stop?egressId=${encodeURIComponent(egressId)}`,
|
||||||
|
)
|
||||||
|
setEgressId(null)
|
||||||
|
toasts.success('Nagrywanie zatrzymane. Plik pojawi się w zakładce Pliki za chwilę.')
|
||||||
|
} else {
|
||||||
|
const response = await client.post<{ egressId: string }>(
|
||||||
|
`/admin/webinars/${webinarId}/recording/start`,
|
||||||
|
)
|
||||||
|
setEgressId(response.data.egressId)
|
||||||
|
toasts.success('Nagrywanie rozpoczęte.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toasts.error(errorMessage(error, 'Nie udało się zmienić stanu nagrywania'))
|
||||||
|
} finally {
|
||||||
|
setRecordingBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Przelaczenie trybu otwartego mikrofonu.
|
* Przelaczenie trybu otwartego mikrofonu.
|
||||||
*
|
*
|
||||||
@@ -230,6 +299,35 @@ export function SpeakQueue({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Nagrywanie. Osobno od trybow mikrofonu i wideo, bo nie dotyczy
|
||||||
|
uprawnien uczestnikow: to decyzja organizatora o tym, czy szkolenie
|
||||||
|
ma zostac utrwalone. Zgoda uczestnikow na nagranie jest jego sprawa.
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleRecording}
|
||||||
|
disabled={recordingBusy}
|
||||||
|
aria-pressed={egressId !== null}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${
|
||||||
|
egressId
|
||||||
|
? 'border-alarm-500/30 bg-alarm-50 text-alarm-600'
|
||||||
|
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={recordingBusy ? 'loader' : 'video'} size={16} className="shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-medium">
|
||||||
|
{egressId ? 'Nagrywanie trwa' : 'Nagraj spotkanie'}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs opacity-80">
|
||||||
|
{egressId
|
||||||
|
? 'Kliknij, żeby zatrzymać. Plik trafi do zakładki Pliki.'
|
||||||
|
: 'Zapis obrazu i dźwięku na serwerze'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user