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

View File

@@ -354,12 +354,21 @@
} }
}, },
"screenRecording": { "screenRecording": {
"start": { "heading": "Diesen Anruf für später aufzeichnen",
"heading": "Dieses Gespräch aufzeichnen", "body": "Zeichnen Sie bis zu {{max_duration}} des Meetings auf.",
"body": "Zeichne dieses Gespräch auf, um es später anzusehen {{duration_message}}. Du erhältst die Videoaufnahme per E-Mail.", "bodyWithoutMaxDuration": "Zeichnen Sie Ihr Meeting unbegrenzt auf.",
"button": "Aufzeichnung starten", "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", "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": { "notAdminOrOwner": {
"heading": "Zugriff eingeschränkt", "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." "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": { "alert": {
"title": "Aufzeichnung fehlgeschlagen", "title": "Aufzeichnung fehlgeschlagen",
"body": { "body": {

View File

@@ -354,12 +354,21 @@
} }
}, },
"screenRecording": { "screenRecording": {
"start": { "heading": "Record this call for later",
"heading": "Record this call", "body": "Record up to {{max_duration}} of meeting.",
"body": "Record this call to watch it later {{duration_message}} and receive the video recording by email.", "bodyWithoutMaxDuration": "Record your meeting without limit.",
"button": "Start recording", "linkMore": "Learn more",
"loading": "Recording starting", "details": {
"linkMore": "Learn more" "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": { "notAdminOrOwner": {
"heading": "Restricted Access", "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." "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": { "alert": {
"title": "Recording Failed", "title": "Recording Failed",
"body": { "body": {

View File

@@ -354,12 +354,21 @@
} }
}, },
"screenRecording": { "screenRecording": {
"start": { "heading": "Enregistrez cet appel pour plus tard",
"heading": "Enregistrer cet appel", "body": "Enregistrez jusqu'à {{max_duration}} de réunion.",
"body": "Enregistrez cet appel pour plus tard {{duration_message}} et recevez l'enregistrement vidéo par mail.", "bodyWithoutMaxDuration": "Enregistrez votre réunion sans limite.",
"button": "Démarrer l'enregistrement", "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", "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": { "notAdminOrOwner": {
"heading": "Accès restreint", "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." "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": { "alert": {
"title": "Échec de l'enregistrement", "title": "Échec de l'enregistrement",
"body": { "body": {

View File

@@ -314,7 +314,7 @@
"body": "Transcribeer tot {{max_duration}} aan vergadertijd.", "body": "Transcribeer tot {{max_duration}} aan vergadertijd.",
"bodyWithoutMaxDuration": "Transcribeer uw vergadering zonder limiet.", "bodyWithoutMaxDuration": "Transcribeer uw vergadering zonder limiet.",
"details": { "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", "destination": "Er wordt een nieuw document aangemaakt op",
"destinationUnknown": "Een nieuw document wordt aangemaakt", "destinationUnknown": "Een nieuw document wordt aangemaakt",
"language": "Vergadertalen:", "language": "Vergadertalen:",
@@ -354,12 +354,21 @@
} }
}, },
"screenRecording": { "screenRecording": {
"start": { "heading": "Neem dit gesprek op voor later",
"heading": "Dit gesprek opnemen", "body": "Neem tot {{max_duration}} van de vergadering op.",
"body": "Neem dit gesprek op om het later terug te kijken {{duration_message}}. Je ontvangt de video-opname per e-mail.", "bodyWithoutMaxDuration": "Neem je vergadering onbeperkt op.",
"button": "Opname starten", "linkMore": "Meer informatie",
"loading": "Opname gestarten", "details": {
"linkMore": "Meer informatie" "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": { "notAdminOrOwner": {
"heading": "Toegang beperkt", "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." "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": { "alert": {
"title": "Opname mislukt", "title": "Opname mislukt",
"body": { "body": {