Skip to content

Commit 8c8dd38

Browse files
prince0xdevclaude
andcommitted
✨(frontend) add Picture-in-Picture recorder - closes #96
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 841945e commit 8c8dd38

10 files changed

Lines changed: 775 additions & 87 deletions

File tree

src/frontend/src/features/recordings/components/RecordComponent.scss

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,27 @@ $mobile: map.get($themes, 'default', 'globals', 'breakpoints', 'md');
4242
}
4343
}
4444

45+
.record-component__tab-audio-toggle {
46+
display: inline-flex;
47+
align-items: center;
48+
gap: 0.5rem;
49+
font-size: 0.875rem;
50+
color: var(--c--contextuals--content--semantic--neutral--primary);
51+
width: 100%;
52+
justify-content: center;
53+
}
54+
55+
.record-component__tab-audio-label {
56+
margin: 0;
57+
color: var(--c--contextuals--content--semantic--neutral--secondary);
58+
font-size: 0.75rem;
59+
width: 100%;
60+
text-align: center;
61+
text-overflow: ellipsis;
62+
overflow: hidden;
63+
white-space: nowrap;
64+
}
65+
4566
.record-component__status {
4667
margin: 0;
4768
display: inline-flex;
@@ -102,45 +123,6 @@ $mobile: map.get($themes, 'default', 'globals', 'breakpoints', 'md');
102123
}
103124
}
104125

105-
.record-component__controls {
106-
display: grid;
107-
grid-template-columns: 1fr 1fr;
108-
gap: 0.75rem;
109-
width: 100%;
110-
}
111-
112-
.record-component__controls--single {
113-
grid-template-columns: minmax(0, 1fr);
114-
width: min(100%, 380px);
115-
}
116-
117-
.record-component__button {
118-
border: none;
119-
border-radius: 6px;
120-
min-height: 2.875rem;
121-
padding: 0.625rem 0.875rem;
122-
display: inline-flex;
123-
justify-content: center;
124-
align-items: center;
125-
gap: 0.5rem;
126-
font-size: 1.0625rem;
127-
font-weight: 500;
128-
cursor: pointer;
129-
transition:
130-
background-color 150ms ease,
131-
color 150ms ease;
132-
133-
.material-icons {
134-
font-size: 1.25rem;
135-
line-height: 1;
136-
color: currentcolor;
137-
}
138-
139-
&:disabled {
140-
opacity: 0.65;
141-
cursor: not-allowed;
142-
}
143-
}
144126

145127
.record-component__button--muted {
146128
background-color: var(--c--globals--colors--gray-150);
@@ -169,3 +151,47 @@ $mobile: map.get($themes, 'default', 'globals', 'breakpoints', 'md');
169151
line-height: 1.4;
170152
}
171153
}
154+
155+
.record-pip-window {
156+
margin: 0;
157+
}
158+
159+
.record-component__controls {
160+
display: grid;
161+
grid-template-columns: 1fr 1fr;
162+
gap: 0.75rem;
163+
width: 100%;
164+
}
165+
166+
.record-component__controls--single {
167+
grid-template-columns: minmax(0, 1fr);
168+
width: min(100%, 380px);
169+
}
170+
171+
.record-component__button {
172+
border: none;
173+
border-radius: 6px;
174+
min-height: 2.875rem;
175+
padding: 0.625rem 0.875rem;
176+
display: inline-flex;
177+
justify-content: center;
178+
align-items: center;
179+
gap: 0.5rem;
180+
font-size: 1.0625rem;
181+
font-weight: 500;
182+
cursor: pointer;
183+
transition:
184+
background-color 150ms ease,
185+
color 150ms ease;
186+
187+
.material-icons {
188+
font-size: 1.25rem;
189+
line-height: 1;
190+
color: currentcolor;
191+
}
192+
193+
&:disabled {
194+
opacity: 0.65;
195+
cursor: not-allowed;
196+
}
197+
}

src/frontend/src/features/recordings/components/RecordComponent.tsx

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { RecordingPictureInPicturePanel } from '@/features/recordings/components/RecordingPictureInPicturePanel.tsx'
12
import { SignalLevelMeter } from '@/features/recordings/components/SignalLevelMeter.tsx'
3+
import { useDocumentPictureInPicture } from '@/features/recordings/hooks/useDocumentPictureInPicture.tsx'
24
import { useRecordingController } from '@/features/recordings/hooks/useRecordingController.ts'
35
import { useDisablePageRefresh } from '@/hooks/disablePageRegresh.ts'
46
import { Button } from '@gouvfr-lasuite/cunningham-react'
@@ -46,34 +48,48 @@ export default function RecordComponent() {
4648
resumeRecording,
4749
stopAndDispose,
4850
switchAudioInput,
51+
tabAudioSupported,
52+
tabAudioActive,
53+
tabAudioLabel,
54+
enableTabAudioCapture,
55+
disableTabAudioCapture,
4956
} = useRecordingController(true)
5057

5158
const [, navigate] = useLocation()
5259
const isRecordingInProgress =
5360
recorderState === 'recording' || recorderState === 'paused'
5461
const isStarting = recorderState === 'starting'
5562
const isStopping = recorderState === 'stopping'
56-
5763
const isBusy = isStarting || isRecordingInProgress || isStopping
64+
const durationLabel = formatDuration(recordingDurationMs)
65+
5866
useDisablePageRefresh(isBusy, t('record:preventGoBackAlert'))
5967

6068
const [openInputSelection, setOpenInputSelection] = useState(false)
69+
const audioInputLabels = useMemo(
70+
() =>
71+
audioInputs.map((input, index) => ({
72+
deviceId: input.deviceId,
73+
label:
74+
input.label ||
75+
t('record:source.fallbackLabel', {
76+
index: index + 1,
77+
}),
78+
})),
79+
[audioInputs, t]
80+
)
6181
const audioInputOptions = useMemo<DropdownMenuOption[]>(
6282
() =>
63-
audioInputs.map(
64-
(input, index) =>
83+
audioInputLabels.map(
84+
(input) =>
6585
({
6686
value: input.deviceId,
6787
isChecked: input.deviceId === selectedAudioInputId,
6888
callback: () => switchAudioInput(input.deviceId),
69-
label:
70-
input.label ||
71-
t('record:source.fallbackLabel', {
72-
index: index + 1,
73-
}),
89+
label: input.label,
7490
}) satisfies DropdownMenuOption
7591
),
76-
[audioInputs, selectedAudioInputId, switchAudioInput, t]
92+
[audioInputLabels, selectedAudioInputId, switchAudioInput]
7793
)
7894
const selectedSourceLabel = useMemo(
7995
() => audioInputOptions.find((option) => option.isChecked)?.label,
@@ -144,6 +160,57 @@ export default function RecordComponent() {
144160
}
145161
}, [isRecordingInProgress, navigate, stopAndDispose])
146162

163+
const handleTabAudioAction = useCallback(async () => {
164+
if (tabAudioActive) {
165+
await disableTabAudioCapture()
166+
return
167+
}
168+
await enableTabAudioCapture()
169+
}, [disableTabAudioCapture, enableTabAudioCapture, tabAudioActive])
170+
171+
const {
172+
portal: pictureInPicturePortal,
173+
openWindow: openPictureInPictureWindow,
174+
isOpen: isPictureInPictureOpen,
175+
supported: isPictureInPictureSupported,
176+
} = useDocumentPictureInPicture({
177+
enabled: isRecordingInProgress,
178+
width: 320,
179+
height: 280,
180+
children: (
181+
<RecordingPictureInPicturePanel
182+
statusLabel={statusLabel}
183+
durationLabel={durationLabel}
184+
analyserNode={analyserNode}
185+
isRecording={recorderState === 'recording'}
186+
isPaused={isPaused}
187+
onPauseResume={() =>
188+
void (isPaused ? resumeRecording() : pauseRecording())
189+
}
190+
onStop={() => void handleStop()}
191+
audioInputs={audioInputLabels}
192+
selectedAudioInputId={selectedAudioInputId}
193+
onSelectAudioInput={(deviceId) => void switchAudioInput(deviceId)}
194+
sourceLabel={t('record:pip.source')}
195+
tabAudioSupported={tabAudioSupported}
196+
tabAudioLabel={tabAudioLabel}
197+
tabAudioButtonLabel={
198+
tabAudioActive
199+
? t('record:tabAudio.remove')
200+
: t('record:tabAudio.add')
201+
}
202+
onTabAudioAction={() => void handleTabAudioAction()}
203+
soundLevelAriaLabel={t('record:source.signalLevelAriaLabel')}
204+
noSoundDetectedLabel={t('record:source.noSoundDetected')}
205+
lowSoundLabel={t('record:source.lowSound')}
206+
soundOkLabel={t('record:source.soundOk')}
207+
pauseLabel={t('record:pauseRecording')}
208+
resumeLabel={t('record:resumeRecording')}
209+
stopLabel={t('record:stopRecording')}
210+
/>
211+
),
212+
})
213+
147214
return (
148215
<div className="record-component">
149216
<div className="record-component__content">
@@ -186,7 +253,7 @@ export default function RecordComponent() {
186253
recorderState === 'paused' ? 'paused' : ''
187254
)}
188255
>
189-
{formatDuration(recordingDurationMs)}
256+
{durationLabel}
190257
</p>
191258
</div>
192259
<div className="record-component__footer">
@@ -229,6 +296,38 @@ export default function RecordComponent() {
229296
</Button>
230297
)}
231298

299+
{isRecordingInProgress && tabAudioSupported && (
300+
<Button
301+
color="neutral"
302+
variant="tertiary"
303+
size="nano"
304+
onClick={() => void handleTabAudioAction()}
305+
>
306+
{tabAudioActive
307+
? t('record:tabAudio.remove')
308+
: t('record:tabAudio.add')}
309+
</Button>
310+
)}
311+
312+
{isRecordingInProgress &&
313+
isPictureInPictureSupported &&
314+
!isPictureInPictureOpen && (
315+
<Button
316+
color="neutral"
317+
variant="tertiary"
318+
size="nano"
319+
onClick={() => void openPictureInPictureWindow()}
320+
>
321+
{t('record:pip.open')}
322+
</Button>
323+
)}
324+
325+
{tabAudioSupported && tabAudioActive && tabAudioLabel && (
326+
<p className="record-component__tab-audio-label">
327+
{t('record:tabAudio.activeLabel', { label: tabAudioLabel })}
328+
</p>
329+
)}
330+
232331
<div className="record-component__controls">
233332
{isRecordingInProgress && (
234333
<>
@@ -275,6 +374,7 @@ export default function RecordComponent() {
275374
{recordingError}
276375
</div>
277376
)}
377+
{pictureInPicturePortal}
278378
</div>
279379
)
280380
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
.record-pip-panel {
2+
font-family: 'Marianne', sans-serif;
3+
display: flex;
4+
flex-direction: column;
5+
align-items: center;
6+
gap: var(--c--globals--spacings--sm);
7+
padding: var(--c--globals--spacings--md);
8+
background: var(--c--globals--colors--white);
9+
width: 100%;
10+
box-sizing: border-box;
11+
}
12+
13+
.record-pip-panel__meter {
14+
width: 100%;
15+
}
16+
17+
.record-pip-panel__meter .signal-level-meter {
18+
width: 100%;
19+
height: 1.75rem;
20+
}
21+
22+
.record-pip-panel__meter .signal-level-meter__status {
23+
margin: 0;
24+
font-size: 0.6875rem;
25+
color: var(--c--contextuals--content--semantic--neutral--secondary);
26+
}
27+
28+
.record-pip-panel__timer {
29+
font-weight: 700;
30+
font-size: 1.5rem;
31+
font-variant-numeric: tabular-nums;
32+
color: var(--c--contextuals--content--semantic--neutral--primary);
33+
letter-spacing: 0.05em;
34+
}
35+
36+
.record-pip-panel__field {
37+
width: 100%;
38+
}
39+
40+
.record-pip-panel__field select {
41+
width: 100%;
42+
border: 1px solid var(--c--globals--colors--gray-200);
43+
border-radius: 4px;
44+
padding: 0.375rem 0.5rem;
45+
font-size: 0.8125rem;
46+
background: var(--c--globals--colors--white);
47+
color: var(--c--contextuals--content--semantic--neutral--primary);
48+
cursor: pointer;
49+
}
50+
51+
.record-pip-panel__tab-audio {
52+
display: flex;
53+
align-items: center;
54+
justify-content: space-between;
55+
width: 100%;
56+
gap: var(--c--globals--spacings--sm);
57+
}
58+
59+
.record-pip-panel__tab-audio strong {
60+
font-size: 0.75rem;
61+
font-weight: 500;
62+
color: var(--c--contextuals--content--semantic--neutral--secondary);
63+
overflow: hidden;
64+
text-overflow: ellipsis;
65+
white-space: nowrap;
66+
}
67+

0 commit comments

Comments
 (0)