🚸(frontend) align screen recording side panel ux

Refactor the screen recording side panel to align with the transcription UX,
ensuring a more consistent and homogeneous user experience.

This commit also introduces a checkbox allowing users to request transcription
of the screen recording, which is one of the most requested features.

The side panel will be enriched with more information soon, especially once
Fichier is integrated for storing recordings, so the destination can be made
explicit.

More recording settings (layout, quality, etc.) will be introduced in upcoming
commits.
This commit is contained in:
lebaudantoine
2025-12-31 18:24:24 +01:00
committed by aleb_the_flash
parent 236245740f
commit 5e1705d259
7 changed files with 213 additions and 203 deletions

View File

@@ -6,14 +6,17 @@ import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasFeatureWithoutAdminRights,
useIsRecordingTransitioning,
useStartRecording,
useStopRecording,
} from '@/features/recording'
import { useEffect, useMemo, useState } from 'react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import {
RecordingLanguage,
RecordingStatus,
recordingStore,
} from '@/stores/recording'
import {
NotificationType,
@@ -28,6 +31,8 @@ import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
import { FeatureFlags } from '@/features/analytics/enums'
import { NoAccessView } from './NoAccessView'
import { HStack, VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -37,6 +42,8 @@ export const ScreenRecordingSidePanel = () => {
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
const [includeTranscript, setIncludeTranscript] = useState(false)
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
@@ -70,7 +77,6 @@ export const ScreenRecordingSidePanel = () => {
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
const isRecordingTransitioning = useIsRecordingTransitioning()
useEffect(() => {
const handleRecordingStatusChanged = () => {
@@ -90,6 +96,7 @@ export const ScreenRecordingSidePanel = () => {
try {
setIsLoading(true)
if (room.isRecording) {
setIncludeTranscript(false)
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
await notifyParticipants({
@@ -100,9 +107,17 @@ export const ScreenRecordingSidePanel = () => {
room.localParticipant
)
} else {
const recordingOptions = {
...(recordingSnap.language != RecordingLanguage.AUTOMATIC && {
language: recordingSnap.language,
}),
...(includeTranscript && { transcribe: true }),
}
await startRecordingRoom({
id: roomId,
mode: RecordingMode.ScreenRecording,
options: recordingOptions,
})
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
await notifyParticipants({
@@ -116,15 +131,6 @@ export const ScreenRecordingSidePanel = () => {
}
}
const isDisabled = useMemo(
() =>
isLoading ||
isRecordingTransitioning ||
statuses.isAnotherModeStarted ||
!isRoomConnected,
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
)
if (hasFeatureWithoutAdminRights) {
return (
<NoAccessView
@@ -147,138 +153,158 @@ export const ScreenRecordingSidePanel = () => {
>
<img
src="/assets/intro-slider/4.png"
alt={''}
alt=""
className={css({
minHeight: '309px',
height: '309px',
minHeight: '250px',
height: '250px',
marginBottom: '1rem',
'@media (max-height: 700px)': {
marginTop: '-16px',
'@media (max-height: 900px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '45%',
marginBottom: '0.3rem',
maxHeight: '25%',
marginBottom: '0.75rem',
},
'@media (max-height: 530px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '40%',
marginBottom: '0.1rem',
'@media (max-height: 770px)': {
display: 'none',
},
})}
/>
{statuses.isStarted ? (
<>
<H lvl={3} margin={false}>
{t('stop.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
<VStack gap={0} marginBottom={30}>
<H lvl={1} margin={'sm'} fullWidth>
{t('heading')}
</H>
<Text variant="body" fullWidth>
{data?.recording?.max_duration
? t('body', {
max_duration: humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
}),
})
: t('bodyWithoutMaxDuration')}{' '}
{data?.support?.help_article_recording && (
<A href={data.support.help_article_recording} target="_blank">
{t('linkMore')}
</A>
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={40}>
<div
className={css({
width: '100%',
background: 'gray.100',
borderRadius: '4px 4px 0 0',
paddingLeft: '4px',
padding: '8px',
display: 'flex',
})}
>
<div
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
'@media (max-height: 700px)': {
marginBottom: '1rem',
},
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
{t('stop.body')}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="stop-screen-recording"
size="sm"
variant="tertiary"
<span className="material-icons">cloud_download</span>
</div>
<div
className={css({
flex: 5,
})}
>
{t('stop.button')}
</Button>
</>
) : (
<>
{statuses.isStopping || isPendingToStop ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
'@media (max-height: 700px)': {
marginBottom: '1rem',
},
})}
>
{t('stopping.body')}
</Text>
<Spinner />
</>
) : (
<>
<H lvl={3} margin={false}>
{t('start.heading')}
</H>
<Text
variant="note"
wrap="balance"
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
'@media (max-height: 700px)': {
marginBottom: '1rem',
},
})}
>
{t('start.body', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
max_duration: humanizeDuration(
data?.recording?.max_duration,
{
language: i18n.language,
}
),
})
: '',
})}{' '}
{data?.support?.help_article_recording && (
<A href={data.support.help_article_recording} target="_blank">
{t('start.linkMore')}
</A>
)}
</Text>
<Text variant="sm">{t('details.destination')}</Text>
</div>
</div>
<div
className={css({
width: '100%',
background: 'gray.100',
borderRadius: '0 0 4px 4px',
paddingLeft: '4px',
padding: '8px',
display: 'flex',
marginTop: '4px',
})}
>
<div
className={css({
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<span className="material-icons">mail</span>
</div>
<div
className={css({
flex: 5,
})}
>
<Text variant="sm">{t('details.receiver')}</Text>
</div>
</div>
<div className={css({ height: '15px' })} />
<div
className={css({
width: '100%',
marginLeft: '20px',
})}
>
<Checkbox
size="sm"
isSelected={includeTranscript}
onChange={setIncludeTranscript}
isDisabled={
statuses.isStarting || statuses.isStarted || isPendingToStart
}
>
<Text variant="sm">{t('details.transcription')}</Text>
</Checkbox>
</div>
</VStack>
<div
className={css({
marginBottom: '80px',
width: '100%',
})}
>
{statuses.isStopping || isPendingToStop ? (
<HStack width={'100%'} height={'46px'} justify="center">
<Spinner size={30} />
<Text variant="body">{t('button.saving')}</Text>
</HStack>
) : (
<>
{statuses.isStarted || statuses.isStarting || room.isRecording ? (
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="start-screen-recording"
size="sm"
variant="tertiary"
fullWidth
onPress={() => handleScreenRecording()}
isDisabled={statuses.isStopping || isPendingToStop || isLoading}
data-attr="stop-transcript"
>
{statuses.isStarting || isPendingToStart ? (
<>
<Spinner size={20} />
{t('start.loading')}
</>
) : (
t('start.button')
)}
{t('button.stop')}
</Button>
</>
)}
</>
)}
) : (
<Button
variant="tertiary"
fullWidth
onPress={() => handleScreenRecording()}
isDisabled={isPendingToStart || !isRoomConnected || isLoading}
data-attr="start-transcript"
>
{t('button.start')}
</Button>
)}
</>
)}
</div>
<Dialog
isOpen={!!isErrorDialogOpen}
role="alertdialog"

View File

@@ -1,15 +0,0 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
export const useIsRecordingTransitioning = () => {
const recordingSnap = useSnapshot(recordingStore)
const transitionalStates = [
RecordingStatus.TRANSCRIPT_STARTING,
RecordingStatus.TRANSCRIPT_STOPPING,
RecordingStatus.SCREEN_RECORDING_STARTING,
RecordingStatus.SCREEN_RECORDING_STOPPING,
]
return transitionalStates.includes(recordingSnap.status)
}

View File

@@ -1,6 +1,5 @@
// hooks
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
export { useIsRecordingActive } from './hooks/useIsRecordingActive'
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'

View File

@@ -354,12 +354,21 @@
}
},
"screenRecording": {
"start": {
"heading": "Dieses Gespräch aufzeichnen",
"body": "Zeichne dieses Gespräch auf, um es später anzusehen {{duration_message}}. Du erhältst die Videoaufnahme per E-Mail.",
"button": "Aufzeichnung starten",
"heading": "Diesen Anruf für später aufzeichnen",
"body": "Zeichnen Sie bis zu {{max_duration}} des Meetings auf.",
"bodyWithoutMaxDuration": "Zeichnen Sie Ihr Meeting unbegrenzt auf.",
"linkMore": "Mehr erfahren",
"details": {
"receiver": "Die Aufzeichnung wird an den Organisator und die Mitorganisatoren gesendet.",
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
"loading": "Aufzeichnung wird gestartet",
"linkMore": "Mehr erfahren"
"linkMore": "Mehr erfahren",
"transcription": "Diese Aufzeichnung transkribieren"
},
"button": {
"start": "Meeting-Aufzeichnung starten",
"stop": "Meeting-Aufzeichnung beenden",
"saving": "Wird gespeichert…"
},
"notAdminOrOwner": {
"heading": "Zugriff eingeschränkt",
@@ -370,15 +379,6 @@
"body": "Nur der Ersteller der Besprechung oder ein Administrator kann die Aufzeichnung starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
}
},
"stopping": {
"heading": "Daten werden gespeichert…",
"body": "Sie können das Meeting verlassen, wenn Sie möchten; die Aufzeichnung wird automatisch beendet."
},
"stop": {
"heading": "Aufzeichnung läuft…",
"body": "Du erhältst das Ergebnis per E-Mail, sobald die Aufzeichnung abgeschlossen ist.",
"button": "Aufzeichnung stoppen"
},
"alert": {
"title": "Aufzeichnung fehlgeschlagen",
"body": {

View File

@@ -354,12 +354,21 @@
}
},
"screenRecording": {
"start": {
"heading": "Record this call",
"body": "Record this call to watch it later {{duration_message}} and receive the video recording by email.",
"button": "Start recording",
"loading": "Recording starting",
"linkMore": "Learn more"
"heading": "Record this call for later",
"body": "Record up to {{max_duration}} of meeting.",
"bodyWithoutMaxDuration": "Record your meeting without limit.",
"linkMore": "Learn more",
"details": {
"receiver": "The recording will be sent to the host and co-hosts.",
"destination": "This recording will be temporarily stored on our servers",
"loading": "Starting recording",
"linkMore": "Learn more",
"transcription": "Transcribe this recording"
},
"button": {
"start": "Start recording the meeting",
"stop": "Stop recording the meeting",
"saving": "Saving…"
},
"notAdminOrOwner": {
"heading": "Restricted Access",
@@ -370,15 +379,6 @@
"body": "Only the meeting creator or an admin can start screen recording. Log in to verify your permissions."
}
},
"stopping": {
"heading": "Saving your data…",
"body": "You can leave the meeting if you wish; the recording will finish automatically."
},
"stop": {
"heading": "Recording in progress…",
"body": "You will receive the result by email once the recording is complete.",
"button": "Stop recording"
},
"alert": {
"title": "Recording Failed",
"body": {

View File

@@ -354,12 +354,21 @@
}
},
"screenRecording": {
"start": {
"heading": "Enregistrer cet appel",
"body": "Enregistrez cet appel pour plus tard {{duration_message}} et recevez l'enregistrement vidéo par mail.",
"button": "Démarrer l'enregistrement",
"heading": "Enregistrez cet appel pour plus tard",
"body": "Enregistrez jusqu'à {{max_duration}} de réunion.",
"bodyWithoutMaxDuration": "Enregistrez votre réunion sans limite.",
"linkMore": "En savoir plus",
"details": {
"receiver": "L'enregistrement sera envoyé à l'organisateur et aux coorganisateurs.",
"destination": "Cet enregistrement sera conservé temporairement sur nos serveurs",
"loading": "Démarrage de l'enregistrement",
"linkMore": "En savoir plus"
"linkMore": "En savoir plus",
"transcription": "Transcrire cet enregistrement"
},
"button": {
"start": "Commencer à enregistrer la réunion",
"stop": "Arrêter d'enregistrer la réunion",
"saving": "Sauvegarde…"
},
"notAdminOrOwner": {
"heading": "Accès restreint",
@@ -370,15 +379,6 @@
"body": "Seul le créateur de la réunion ou un administrateur peut démarrer l'enregistrement. Connectez-vous pour vérifier vos autorisations."
}
},
"stopping": {
"heading": "Sauvegarde de vos données…",
"body": "Vous pouvez quitter la réunion si vous le souhaitez, la sauvegarde se terminera automatiquement."
},
"stop": {
"heading": "Enregistrement en cours …",
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
"button": "Arrêter l'enregistrement"
},
"alert": {
"title": "Échec de l'enregistrement",
"body": {

View File

@@ -314,7 +314,7 @@
"body": "Transcribeer tot {{max_duration}} aan vergadertijd.",
"bodyWithoutMaxDuration": "Transcribeer uw vergadering zonder limiet.",
"details": {
"receiver": "Het transcript wordt verzonden naar de organisator en medeorganisatoren.",
"receiver": "Het transcript wordt verzonden naar de host en de co-host.",
"destination": "Er wordt een nieuw document aangemaakt op",
"destinationUnknown": "Een nieuw document wordt aangemaakt",
"language": "Vergadertalen:",
@@ -354,12 +354,21 @@
}
},
"screenRecording": {
"start": {
"heading": "Dit gesprek opnemen",
"body": "Neem dit gesprek op om het later terug te kijken {{duration_message}}. Je ontvangt de video-opname per e-mail.",
"button": "Opname starten",
"loading": "Opname gestarten",
"linkMore": "Meer informatie"
"heading": "Neem dit gesprek op voor later",
"body": "Neem tot {{max_duration}} van de vergadering op.",
"bodyWithoutMaxDuration": "Neem je vergadering onbeperkt op.",
"linkMore": "Meer informatie",
"details": {
"receiver": "De opname wordt verzonden naar de organisator en co-organisatoren.",
"destination": "Deze opname wordt tijdelijk op onze servers bewaard",
"loading": "Opname wordt gestart",
"linkMore": "Meer informatie",
"transcription": "Transcribeer deze opname"
},
"button": {
"start": "Start met opnemen van de vergadering",
"stop": "Stop met opnemen van de vergadering",
"saving": "Bezig met opslaan…"
},
"notAdminOrOwner": {
"heading": "Toegang beperkt",
@@ -370,15 +379,6 @@
"body": "Alleen de maker van de vergadering of een beheerder kan de opname starten. Log in om uw machtigingen te controleren."
}
},
"stopping": {
"heading": "Uw gegevens worden opgeslagen…",
"body": "U kunt de vergadering verlaten als u dat wilt; de opname wordt automatisch voltooid."
},
"stop": {
"heading": "Opname bezig …",
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
"button": "Opname stoppen"
},
"alert": {
"title": "Opname mislukt",
"body": {