Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59bb1eccee |
@@ -22,8 +22,6 @@ and this project adheres to
|
||||
- ♿(frontend) improve contrast for selected options #863
|
||||
- ♿️(frontend) announce copy state in invite dialog #877
|
||||
- 📝(frontend) align close dialog label in rooms locale #878
|
||||
- 🩹(backend) use case-insensitive email matching in the external api #887
|
||||
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
|
||||
|
||||
## [1.3.0] - 2026-01-13
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
import jwt
|
||||
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(email__iexact=email)
|
||||
user = models.User.objects.get(email=email)
|
||||
except models.User.DoesNotExist as e:
|
||||
if (
|
||||
settings.APPLICATION_ALLOW_USER_CREATION
|
||||
@@ -123,10 +123,6 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
else:
|
||||
raise drf_exceptions.NotFound("User not found.") from e
|
||||
except models.User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
scope = " ".join(application.scopes or [])
|
||||
|
||||
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
UserFactory(email="User.Family@example.com")
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "user.family@example.com",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
+2
@@ -7,6 +7,7 @@ import { EffectsMenuItem } from './EffectsMenuItem'
|
||||
import { SupportMenuItem } from './SupportMenuItem'
|
||||
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
||||
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
|
||||
import { PiPMenuItem } from './PiPMenuItem'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = () => {
|
||||
@@ -20,6 +21,7 @@ export const OptionsMenuItems = () => {
|
||||
<MenuSection>
|
||||
<TranscriptMenuItem />
|
||||
<ScreenRecordingMenuItem />
|
||||
<PiPMenuItem />
|
||||
<FullScreenMenuItem />
|
||||
<EffectsMenuItem />
|
||||
</MenuSection>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import React from 'react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Button } from '@/primitives'
|
||||
import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton'
|
||||
import { RoomContext } from '@livekit/components-react'
|
||||
import { RiPictureInPicture2Line } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
|
||||
import { Track } from 'livekit-client'
|
||||
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle.tsx'
|
||||
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx'
|
||||
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl.tsx'
|
||||
import { PiPOptionsButton } from '@/features/rooms/livekit/components/controls/Options/PiPOptionsMenuItems.tsx'
|
||||
|
||||
// Composant à afficher dans le PiP
|
||||
const PiPContent = ({ room, onClose }) => {
|
||||
return (
|
||||
<RoomContext.Provider value={room}>
|
||||
<div
|
||||
className={css({
|
||||
height: '100vh',
|
||||
position: 'relative',
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
>
|
||||
<h2>Room: {room?.name}</h2>
|
||||
<Button onPress={onClose} variant="danger">
|
||||
Close PiP
|
||||
</Button>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
margin: '0.25rem',
|
||||
marginBottom: '0.75rem',
|
||||
width: 'calc(100% - 0.5rem)',
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
// border: '1px solid white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<AudioDevicesControl
|
||||
hideMenu={true}
|
||||
onDeviceError={(error) => console.log(error)}
|
||||
/>
|
||||
<VideoDeviceControl
|
||||
hideMenu={true}
|
||||
onDeviceError={(error) => console.log(error)}
|
||||
/>
|
||||
<ScreenShareToggle onDeviceError={(error) => console.log(error)} />
|
||||
<HandToggle />
|
||||
<LeaveButton />
|
||||
</div>
|
||||
</div>
|
||||
</RoomContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const PiPMenuItem = () => {
|
||||
const room = useRoomContext()
|
||||
|
||||
const openDocumentPiP = async () => {
|
||||
try {
|
||||
if (!('documentPictureInPicture' in window)) {
|
||||
alert('Document Picture-in-Picture not supported')
|
||||
return
|
||||
}
|
||||
|
||||
const pipWindow = await window.documentPictureInPicture.requestWindow({
|
||||
width: 400,
|
||||
height: 300,
|
||||
})
|
||||
|
||||
// Copier les styles CSS
|
||||
const allCSS = [...document.styleSheets]
|
||||
.map((styleSheet) => {
|
||||
try {
|
||||
return [...styleSheet.cssRules].map((rule) => rule.cssText).join('')
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const styleElement = pipWindow.document.createElement('style')
|
||||
styleElement.textContent = allCSS
|
||||
pipWindow.document.head.appendChild(styleElement)
|
||||
|
||||
// Créer conteneur React
|
||||
const container = pipWindow.document.createElement('div')
|
||||
pipWindow.document.body.appendChild(container)
|
||||
|
||||
// Render avec la valeur room passée
|
||||
const root = createRoot(container)
|
||||
root.render(
|
||||
<PiPContent
|
||||
room={room} // Passer l'objet room
|
||||
onClose={() => pipWindow.close()}
|
||||
/>
|
||||
)
|
||||
|
||||
// Nettoyage
|
||||
pipWindow.addEventListener('pagehide', () => {
|
||||
root.unmount()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to open PiP:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onAction={openDocumentPiP}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiPictureInPicture2Line size={20} />
|
||||
Open in PiP
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Menu as RACMenu } from 'react-aria-components'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMore2Line } from '@remixicon/react'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { OptionsMenuItems } from './OptionsMenuItems'
|
||||
import { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Options/EffectsMenuItem'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const PiPOptionsMenuItems = () => {
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
minWidth: '150px',
|
||||
width: '300px',
|
||||
}}
|
||||
>
|
||||
<EffectsMenuItem />
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const PiPOptionsButton = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
return (
|
||||
<Menu variant="dark">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
>
|
||||
<RiMore2Line />
|
||||
</Button>
|
||||
<PiPOptionsMenuItems />
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSubtitles } from '../hooks/useSubtitles'
|
||||
import { css, cva } from '@/styled-system/css'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
@@ -13,63 +13,101 @@ export interface TranscriptionSegment {
|
||||
id: string
|
||||
text: string
|
||||
language: string
|
||||
startTime?: number
|
||||
startTime: number
|
||||
endTime: number
|
||||
final: boolean
|
||||
firstReceivedTime: number
|
||||
lastReceivedTime: number
|
||||
}
|
||||
|
||||
export interface TranscriptionSegmentWithParticipant
|
||||
extends TranscriptionSegment {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export interface TranscriptionRow {
|
||||
id: string
|
||||
participant: Participant
|
||||
segments: TranscriptionSegment[]
|
||||
startTime?: number
|
||||
startTime: number
|
||||
lastUpdateTime: number
|
||||
lastReceivedTime: number
|
||||
}
|
||||
|
||||
const useTranscriptionState = () => {
|
||||
const [transcriptionSegments, setTranscriptionSegments] = useState<
|
||||
TranscriptionSegmentWithParticipant[]
|
||||
const [transcriptionRows, setTranscriptionRows] = useState<
|
||||
TranscriptionRow[]
|
||||
>([])
|
||||
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
|
||||
useState<string | null>(null)
|
||||
|
||||
const updateTranscriptionSegments = (
|
||||
const updateTranscriptions = (
|
||||
segments: TranscriptionSegment[],
|
||||
participant?: Participant
|
||||
) => {
|
||||
console.log(participant, segments)
|
||||
|
||||
if (!participant || segments.length === 0) return
|
||||
|
||||
if (segments.length > 1) {
|
||||
console.warn('Unexpected error more segments')
|
||||
return
|
||||
}
|
||||
setTranscriptionRows((prevRows) => {
|
||||
const updatedRows = [...prevRows]
|
||||
const now = Date.now()
|
||||
|
||||
const segment = segments[0]
|
||||
const shouldAppendToLastRow =
|
||||
lastActiveParticipantIdentity === participant.identity &&
|
||||
updatedRows.length > 0
|
||||
|
||||
setTranscriptionSegments((prevSegments) => {
|
||||
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
|
||||
if (existingSegmentIds.has(segment.id)) return prevSegments
|
||||
return [
|
||||
...prevSegments,
|
||||
{
|
||||
participant: participant,
|
||||
...segment,
|
||||
},
|
||||
]
|
||||
if (shouldAppendToLastRow) {
|
||||
const lastRowIndex = updatedRows.length - 1
|
||||
const lastRow = updatedRows[lastRowIndex]
|
||||
|
||||
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
|
||||
const newSegments = segments.filter(
|
||||
(segment) => !existingSegmentIds.has(segment.id)
|
||||
)
|
||||
const updatedSegments = lastRow.segments.map((existing) => {
|
||||
const update = segments.find((s) => s.id === existing.id)
|
||||
return update && update.final ? update : existing
|
||||
})
|
||||
|
||||
updatedRows[lastRowIndex] = {
|
||||
...lastRow,
|
||||
segments: [...updatedSegments, ...newSegments],
|
||||
lastUpdateTime: now,
|
||||
}
|
||||
} else {
|
||||
const newRow: TranscriptionRow = {
|
||||
id: `${participant.identity}-${now}`,
|
||||
participant,
|
||||
segments: [...segments],
|
||||
startTime: Math.min(...segments.map((s) => s.startTime)),
|
||||
lastUpdateTime: now,
|
||||
}
|
||||
updatedRows.push(newRow)
|
||||
}
|
||||
|
||||
return updatedRows
|
||||
})
|
||||
|
||||
setLastActiveParticipantIdentity(participant.identity)
|
||||
}
|
||||
|
||||
const clearTranscriptions = () => {
|
||||
setTranscriptionRows([])
|
||||
setLastActiveParticipantIdentity(null)
|
||||
}
|
||||
|
||||
const updateParticipant = (_name: string, participant: Participant) => {
|
||||
setTranscriptionRows((prevRows) => {
|
||||
return prevRows.map((row) => {
|
||||
if (row.participant.identity === participant.identity) {
|
||||
return {
|
||||
...row,
|
||||
participant,
|
||||
}
|
||||
}
|
||||
return row
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
updateTranscriptionSegments,
|
||||
transcriptionSegments,
|
||||
transcriptionRows,
|
||||
updateTranscriptions,
|
||||
clearTranscriptions,
|
||||
updateParticipant,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,54 +192,24 @@ const SubtitlesWrapper = styled(
|
||||
export const Subtitles = () => {
|
||||
const { areSubtitlesOpen } = useSubtitles()
|
||||
const room = useRoomContext()
|
||||
|
||||
const { transcriptionSegments, updateTranscriptionSegments } =
|
||||
const { transcriptionRows, updateTranscriptions, updateParticipant } =
|
||||
useTranscriptionState()
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
||||
return () => {
|
||||
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
||||
}
|
||||
}, [room, updateTranscriptionSegments])
|
||||
}, [room, updateTranscriptions])
|
||||
|
||||
const transcriptionRows = useMemo(() => {
|
||||
if (transcriptionSegments.length === 0) return []
|
||||
|
||||
const rows: TranscriptionRow[] = []
|
||||
let currentRow: TranscriptionRow | null = null
|
||||
|
||||
for (const segment of transcriptionSegments) {
|
||||
const shouldStartNewRow =
|
||||
!currentRow ||
|
||||
currentRow.participant.identity !== segment.participant.identity
|
||||
|
||||
if (shouldStartNewRow) {
|
||||
currentRow = {
|
||||
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
|
||||
participant: segment.participant,
|
||||
segments: [segment],
|
||||
startTime: segment.startTime,
|
||||
lastUpdateTime: segment.lastReceivedTime,
|
||||
lastReceivedTime: segment.lastReceivedTime,
|
||||
}
|
||||
rows.push(currentRow)
|
||||
} else if (currentRow) {
|
||||
currentRow.segments.push(segment)
|
||||
currentRow.lastUpdateTime = Math.max(
|
||||
currentRow.lastUpdateTime,
|
||||
segment.lastReceivedTime
|
||||
)
|
||||
currentRow.lastReceivedTime = Math.max(
|
||||
currentRow.lastReceivedTime,
|
||||
segment.lastReceivedTime
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
|
||||
}
|
||||
|
||||
return rows
|
||||
}, [transcriptionSegments])
|
||||
}, [room, updateParticipant])
|
||||
|
||||
return (
|
||||
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
|
||||
|
||||
Reference in New Issue
Block a user