(frontend) handle another recording mode is active

Refactor literals in the recording status hook and introduce a new status.
Align the login prompt style with the newly introduced warning message, and
guide users by clearly indicating that the two modes are mutually exclusive.
Users are prompted to stop the other mode before starting a new one.

This situation should happen less often now that checkboxes allow users to start
transcription and recording together. Hopefully, the UX is clear enough.

The growing number of props passed to the controls buttons may become an issue
and will likely require refactoring later.
This commit is contained in:
lebaudantoine
2026-01-02 12:41:22 +01:00
committed by aleb_the_flash
parent 9d69fe4f4f
commit 70403ad0d8
10 changed files with 117 additions and 16 deletions

View File

@@ -1,4 +1,4 @@
import { css } from '@/styled-system/css' import { css, cx } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { Button, Text } from '@/primitives' import { Button, Text } from '@/primitives'
@@ -7,6 +7,8 @@ import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react' import { ReactNode, useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { ConnectionState } from 'livekit-client' import { ConnectionState } from 'livekit-client'
import { Button as RACButton } from 'react-aria-components'
import { parseLineBreaks } from '@/utils/parseLineBreaks'
const Layout = ({ children }: { children: ReactNode }) => ( const Layout = ({ children }: { children: ReactNode }) => (
<div <div
@@ -25,6 +27,7 @@ interface ControlsButtonProps {
handle: () => void handle: () => void
isPendingToStart: boolean isPendingToStart: boolean
isPendingToStop: boolean isPendingToStop: boolean
openSidePanel: () => void
} }
const MIN_SPINNER_DISPLAY_TIME = 2000 const MIN_SPINNER_DISPLAY_TIME = 2000
@@ -35,6 +38,7 @@ export const ControlsButton = ({
handle, handle,
isPendingToStart, isPendingToStart,
isPendingToStop, isPendingToStop,
openSidePanel,
}: ControlsButtonProps) => { }: ControlsButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix }) const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
@@ -45,7 +49,7 @@ export const ControlsButton = ({
const timeoutRef = useRef<NodeJS.Timeout>() const timeoutRef = useRef<NodeJS.Timeout>()
const isSaving = statuses.isSaving || isPendingToStop const isSaving = statuses.isSaving || isPendingToStop
const isDisabled = !isRoomConnected const isDisabled = !isRoomConnected || statuses.isAnotherModeStarted
useEffect(() => { useEffect(() => {
if (isSaving) { if (isSaving) {
@@ -103,8 +107,57 @@ export const ControlsButton = ({
// Inactive state (Start button) // Inactive state (Start button)
return ( return (
<Layout> <Layout>
{statuses.isAnotherModeStarted && (
<RACButton
className={css({
backgroundColor: 'primary.50',
border: '1px solid',
borderColor: 'primary.200',
borderRadius: '6px',
padding: '0.75rem',
marginBottom: '0.75rem',
display: 'flex',
justifyContent: 'left',
textAlign: 'left',
alignItems: 'center',
width: '100%',
cursor: 'pointer',
_hover: {
backgroundColor: 'primary.100',
borderColor: 'primary.400',
},
})}
onPress={() => openSidePanel()}
>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginRight: '1rem',
})
)}
>
info
</span>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginLeft: 'auto',
})
)}
>
chevron_right
</span>
</RACButton>
)}
<Button <Button
variant="tertiary" variant={isDisabled ? 'primary' : 'tertiary'}
fullWidth fullWidth
onPress={handle} onPress={handle}
isDisabled={isDisabled} isDisabled={isDisabled}

View File

@@ -13,6 +13,8 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
className={css({ className={css({
backgroundColor: 'primary.50', backgroundColor: 'primary.50',
borderRadius: '5px', borderRadius: '5px',
border: '1px solid',
borderColor: 'primary.200',
paddingY: '1rem', paddingY: '1rem',
paddingX: '1rem', paddingX: '1rem',
marginTop: '1rem', marginTop: '1rem',

View File

@@ -27,6 +27,7 @@ import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox' import { Checkbox } from '@/primitives/Checkbox'
import { useTranscriptionLanguage } from '@/features/settings' import { useTranscriptionLanguage } from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording' import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
export const ScreenRecordingSidePanel = () => { export const ScreenRecordingSidePanel = () => {
const { data } = useConfig() const { data } = useConfig()
@@ -54,6 +55,7 @@ export const ScreenRecordingSidePanel = () => {
const statuses = useRecordingStatuses(RecordingMode.ScreenRecording) const statuses = useRecordingStatuses(RecordingMode.ScreenRecording)
const room = useRoomContext() const room = useRoomContext()
const { openTranscript } = useSidePanel()
const handleScreenRecording = async () => { const handleScreenRecording = async () => {
if (!roomId) { if (!roomId) {
@@ -184,6 +186,7 @@ export const ScreenRecordingSidePanel = () => {
statuses={statuses} statuses={statuses}
isPendingToStart={isPendingToStart} isPendingToStart={isPendingToStart}
isPendingToStop={isPendingToStop} isPendingToStop={isPendingToStop}
openSidePanel={openTranscript}
/> />
</Div> </Div>
) )

View File

@@ -32,6 +32,7 @@ import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton' import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper' import { RowWrapper } from './RowWrapper'
import { useMutateRecording } from '../hooks/useMutateRecording' import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
export const TranscriptSidePanel = () => { export const TranscriptSidePanel = () => {
const { data } = useConfig() const { data } = useConfig()
@@ -66,6 +67,7 @@ export const TranscriptSidePanel = () => {
const statuses = useRecordingStatuses(RecordingMode.Transcript) const statuses = useRecordingStatuses(RecordingMode.Transcript)
const room = useRoomContext() const room = useRoomContext()
const { openScreenRecording } = useSidePanel()
const handleTranscript = async () => { const handleTranscript = async () => {
if (!roomId) { if (!roomId) {
@@ -241,6 +243,7 @@ export const TranscriptSidePanel = () => {
statuses={statuses} statuses={statuses}
isPendingToStart={isPendingToStart} isPendingToStart={isPendingToStart}
isPendingToStop={isPendingToStop} isPendingToStop={isPendingToStop}
openSidePanel={openScreenRecording}
/> />
</Div> </Div>
) )

View File

@@ -2,7 +2,20 @@ import { RecordingMode } from '@/features/recording'
import { useRoomMetadata } from './useRoomMetadata' import { useRoomMetadata } from './useRoomMetadata'
import { useMemo } from 'react' import { useMemo } from 'react'
export enum RecordingStatus {
Starting = 'starting',
Started = 'started',
Saving = 'saving',
}
const ACTIVE_STATUSES = [
RecordingStatus.Starting,
RecordingStatus.Started,
RecordingStatus.Saving,
] as const
export interface RecordingStatuses { export interface RecordingStatuses {
isAnotherModeStarted: boolean
isStarting: boolean isStarting: boolean
isStarted: boolean isStarted: boolean
isSaving: boolean isSaving: boolean
@@ -17,16 +30,23 @@ export const useRecordingStatuses = (
return useMemo(() => { return useMemo(() => {
if (metadata && metadata?.recording_mode === mode) { if (metadata && metadata?.recording_mode === mode) {
return { return {
isStarting: metadata.recording_status === 'starting', isAnotherModeStarted: false,
isStarted: metadata.recording_status === 'started', isStarting: metadata.recording_status === RecordingStatus.Starting,
isSaving: metadata.recording_status === 'saving', isStarted: metadata.recording_status === RecordingStatus.Started,
isActive: ['starting', 'started', 'saving'].includes( isSaving: metadata.recording_status === RecordingStatus.Saving,
metadata.recording_status isActive: ACTIVE_STATUSES.includes(
metadata.recording_status as RecordingStatus
), ),
} }
} }
const isAnotherModeStarted =
!!metadata?.recording_mode &&
metadata?.recording_mode !== mode &&
ACTIVE_STATUSES.includes(metadata.recording_status as RecordingStatus)
return { return {
isAnotherModeStarted,
isStarting: false, isStarting: false,
isStarted: false, isStarted: false,
isSaving: false, isSaving: false,

View File

@@ -324,7 +324,8 @@
"start": "Meeting transkribieren starten", "start": "Meeting transkribieren starten",
"stop": "Meeting transkribieren beenden", "stop": "Meeting transkribieren beenden",
"saving": "Speichern…", "saving": "Speichern…",
"starting": "Wird gestartet…" "starting": "Wird gestartet…",
"anotherModeStarted": "Eine Aufnahme läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
}, },
"linkMore": "Mehr erfahren", "linkMore": "Mehr erfahren",
"notAdminOrOwner": { "notAdminOrOwner": {
@@ -362,7 +363,8 @@
"start": "Meeting-Aufzeichnung starten", "start": "Meeting-Aufzeichnung starten",
"stop": "Meeting-Aufzeichnung beenden", "stop": "Meeting-Aufzeichnung beenden",
"saving": "Wird gespeichert…", "saving": "Wird gespeichert…",
"starting": "Wird gestartet…" "starting": "Wird gestartet…",
"anotherModeStarted": "Eine Transkription läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
}, },
"notAdminOrOwner": { "notAdminOrOwner": {
"heading": "Zugriff eingeschränkt", "heading": "Zugriff eingeschränkt",

View File

@@ -324,7 +324,8 @@
"start": "Start transcribing the meeting", "start": "Start transcribing the meeting",
"stop": "Stop transcribing the meeting", "stop": "Stop transcribing the meeting",
"saving": "Saving…", "saving": "Saving…",
"starting": "Starting…" "starting": "Starting…",
"anotherModeStarted": "Screen recording is running. Switch panels and stop it."
}, },
"linkMore": "Learn more", "linkMore": "Learn more",
"notAdminOrOwner": { "notAdminOrOwner": {
@@ -362,7 +363,8 @@
"start": "Start recording the meeting", "start": "Start recording the meeting",
"stop": "Stop recording the meeting", "stop": "Stop recording the meeting",
"saving": "Saving…", "saving": "Saving…",
"starting": "Starting…" "starting": "Starting…",
"anotherModeStarted": "Transcription is running. <br/> Switch panels and stop it."
}, },
"notAdminOrOwner": { "notAdminOrOwner": {
"heading": "Restricted Access", "heading": "Restricted Access",

View File

@@ -324,7 +324,8 @@
"start": "Commencer à transcrire la réunion", "start": "Commencer à transcrire la réunion",
"stop": "Arrêter de transcrire la réunion", "stop": "Arrêter de transcrire la réunion",
"saving": "Sauvegarde…", "saving": "Sauvegarde…",
"starting": "Démarrage…" "starting": "Démarrage…",
"anotherModeStarted": "Un enregistrement est en cours. <br/> Changez de menu et arrêtez le."
}, },
"linkMore": "En savoir plus", "linkMore": "En savoir plus",
"notAdminOrOwner": { "notAdminOrOwner": {
@@ -362,7 +363,8 @@
"start": "Commencer à enregistrer la réunion", "start": "Commencer à enregistrer la réunion",
"stop": "Arrêter d'enregistrer la réunion", "stop": "Arrêter d'enregistrer la réunion",
"saving": "Sauvegarde…", "saving": "Sauvegarde…",
"starting": "Démarrage…" "starting": "Démarrage…",
"anotherModeStarted": "Une transcription est en cours. <br/> Changez de menu et arrêtez la."
}, },
"notAdminOrOwner": { "notAdminOrOwner": {
"heading": "Accès restreint", "heading": "Accès restreint",

View File

@@ -324,7 +324,8 @@
"start": "Begin met het transcriberen van de vergadering", "start": "Begin met het transcriberen van de vergadering",
"stop": "Stop met het transcriberen van de vergadering", "stop": "Stop met het transcriberen van de vergadering",
"saving": "Opslaan…", "saving": "Opslaan…",
"starting": "Wordt gestart…" "starting": "Wordt gestart…",
"anotherModeStarted": "Er loopt een opname. <br/> Ga naar het menu en stop deze."
}, },
"linkMore": "Meer informatie", "linkMore": "Meer informatie",
"notAdminOrOwner": { "notAdminOrOwner": {
@@ -362,7 +363,8 @@
"start": "Start met opnemen van de vergadering", "start": "Start met opnemen van de vergadering",
"stop": "Stop met opnemen van de vergadering", "stop": "Stop met opnemen van de vergadering",
"saving": "Bezig met opslaan…", "saving": "Bezig met opslaan…",
"starting": "Wordt gestart…" "starting": "Wordt gestart…",
"anotherModeStarted": "Er loopt een transcriptie. <br/> Ga naar het menu en stop deze."
}, },
"notAdminOrOwner": { "notAdminOrOwner": {
"heading": "Toegang beperkt", "heading": "Toegang beperkt",

View File

@@ -0,0 +1,12 @@
import { Fragment, ReactNode } from 'react'
export const parseLineBreaks = (text: string): ReactNode[] => {
const parts = text.split(/(<br\s*\/?>)/gi)
return parts.map((part, index) => {
if (part.match(/^<br\s*\/?>$/gi)) {
return <br key={index} />
}
return <Fragment key={index}>{part}</Fragment>
})
}