(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 { Spinner } from '@/primitives/Spinner'
import { Button, Text } from '@/primitives'
@@ -7,6 +7,8 @@ import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { ConnectionState } from 'livekit-client'
import { Button as RACButton } from 'react-aria-components'
import { parseLineBreaks } from '@/utils/parseLineBreaks'
const Layout = ({ children }: { children: ReactNode }) => (
<div
@@ -25,6 +27,7 @@ interface ControlsButtonProps {
handle: () => void
isPendingToStart: boolean
isPendingToStop: boolean
openSidePanel: () => void
}
const MIN_SPINNER_DISPLAY_TIME = 2000
@@ -35,6 +38,7 @@ export const ControlsButton = ({
handle,
isPendingToStart,
isPendingToStop,
openSidePanel,
}: ControlsButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
@@ -45,7 +49,7 @@ export const ControlsButton = ({
const timeoutRef = useRef<NodeJS.Timeout>()
const isSaving = statuses.isSaving || isPendingToStop
const isDisabled = !isRoomConnected
const isDisabled = !isRoomConnected || statuses.isAnotherModeStarted
useEffect(() => {
if (isSaving) {
@@ -103,8 +107,57 @@ export const ControlsButton = ({
// Inactive state (Start button)
return (
<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
variant="tertiary"
variant={isDisabled ? 'primary' : 'tertiary'}
fullWidth
onPress={handle}
isDisabled={isDisabled}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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