Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 59bb1eccee wip poc pip in picture 2026-01-20 17:17:47 +01:00
7 changed files with 250 additions and 81 deletions
-2
View File
@@ -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
+2 -6
View File
@@ -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",
)
@@ -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>
)
}
@@ -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}>