This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* RecordingIndicator — compact red pill shown in the UserArea
|
||||
* (next to the mic / deafen controls) while a voice recording is
|
||||
* in progress. Pulses softly so it's visible at a glance; clicking
|
||||
* opens a confirm dialog that stops the recording.
|
||||
*/
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(237, 66, 69, 0.4);
|
||||
border-radius: 999px;
|
||||
background: rgba(237, 66, 69, 0.12);
|
||||
color: var(--status-danger, #ed4245);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: rgba(237, 66, 69, 0.2);
|
||||
border-color: rgba(237, 66, 69, 0.6);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--status-danger, #ed4245);
|
||||
box-shadow: 0 0 0 0 rgba(237, 66, 69, 0.5);
|
||||
animation: recPulse 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes recPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(237, 66, 69, 0.55);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(237, 66, 69, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(237, 66, 69, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
60
packages/shared/src/components/voice/RecordingIndicator.tsx
Normal file
60
packages/shared/src/components/voice/RecordingIndicator.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* RecordingIndicator — red "REC" pill with a running session
|
||||
* timer, shown in the UserArea while a voice recording is in
|
||||
* progress. Clicking it prompts to stop. Gated on the
|
||||
* `isRecording` flag from VoiceContext; renders `null` otherwise
|
||||
* so the UserArea footer shape stays consistent.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import styles from './RecordingIndicator.module.css';
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '00:00';
|
||||
const total = Math.floor(ms / 1000);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const ss = String(s).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export function RecordingIndicator() {
|
||||
const voice = useVoice() as any;
|
||||
const isRecording = !!voice?.isRecording;
|
||||
const startedAt: number | null = voice?.recordingStartedAt ?? null;
|
||||
const stopRecording: (() => Promise<void>) | undefined = voice?.stopRecording;
|
||||
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecording) return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [isRecording]);
|
||||
|
||||
if (!isRecording || !startedAt) return null;
|
||||
|
||||
const elapsed = now - startedAt;
|
||||
|
||||
const handleClick = async () => {
|
||||
const ok = window.confirm('Stop recording this call?');
|
||||
if (!ok) return;
|
||||
await stopRecording?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pill}
|
||||
onClick={handleClick}
|
||||
aria-label={`Stop recording (${formatElapsed(elapsed)})`}
|
||||
title="Click to stop recording"
|
||||
>
|
||||
<span className={styles.dot} aria-hidden />
|
||||
<span className={styles.label}>REC</span>
|
||||
<span className={styles.timer}>{formatElapsed(elapsed)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
.card {
|
||||
padding: 20px 22px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 520px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
border-radius: 8px;
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowPrimary {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowSecondary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
border-radius: 6px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.actionDanger:hover {
|
||||
background: rgba(237, 66, 69, 0.12);
|
||||
border-color: rgba(237, 66, 69, 0.4);
|
||||
color: var(--status-danger, #ed4245);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
218
packages/shared/src/components/voice/RecordingRecoveryModal.tsx
Normal file
218
packages/shared/src/components/voice/RecordingRecoveryModal.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* RecordingRecoveryModal — shown once on app startup when the
|
||||
* Electron main process reports one or more recording sessions
|
||||
* with `endedAt === null`. Those are sessions that didn't
|
||||
* finalize (e.g. the app was killed mid-recording).
|
||||
*
|
||||
* Each session offers three actions:
|
||||
* - Keep — stamps `endedAt` to the latest track's mtime so
|
||||
* the manifest parses cleanly on future scans.
|
||||
* - Delete — removes the session folder entirely.
|
||||
* - Open — reveals the session folder in the OS file browser
|
||||
* so the user can manually inspect / copy out files.
|
||||
*
|
||||
* Gated on `platform.features.hasRecording` — the modal never
|
||||
* renders on web/Android.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FolderOpen, Trash } from '@phosphor-icons/react';
|
||||
import { Modal, Button } from '@discord-clone/ui';
|
||||
import { usePlatform } from '../../platform';
|
||||
import styles from './RecordingRecoveryModal.module.css';
|
||||
|
||||
interface RecoverableSession {
|
||||
sessionId: string;
|
||||
sessionDir: string;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
startedAt: number;
|
||||
participantCount: number;
|
||||
trackCount: number;
|
||||
}
|
||||
|
||||
function formatStartedAt(ts: number): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
export function RecordingRecoveryModal() {
|
||||
const platform = usePlatform() as any;
|
||||
const hasRecording = !!platform?.features?.hasRecording;
|
||||
const recording = platform?.recording;
|
||||
|
||||
const [sessions, setSessions] = useState<RecoverableSession[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRecording || !recording) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
// Look up the user-preferred root (same place the
|
||||
// recorder reads from) so we scan the right folder.
|
||||
let rootDir: string | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem('voiceSettings');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed.recordingDir === 'string') {
|
||||
rootDir = parsed.recordingDir;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const res = await recording.listRecoverable({ rootDir });
|
||||
if (cancelled) return;
|
||||
if (res?.ok && Array.isArray(res.sessions) && res.sessions.length > 0) {
|
||||
setSessions(res.sessions);
|
||||
setIsOpen(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Recording recovery scan failed:', err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasRecording, recording]);
|
||||
|
||||
if (!hasRecording) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
setIsOpen(false);
|
||||
setSessions([]);
|
||||
};
|
||||
|
||||
const handleKeep = async (session: RecoverableSession) => {
|
||||
if (!recording) return;
|
||||
setBusy(session.sessionId);
|
||||
try {
|
||||
await recording.recoverSession({
|
||||
sessionDir: session.sessionDir,
|
||||
action: 'keep',
|
||||
});
|
||||
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
|
||||
} catch (err) {
|
||||
console.warn('Failed to keep recovered session:', err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (session: RecoverableSession) => {
|
||||
if (!recording) return;
|
||||
const ok = window.confirm(
|
||||
`Delete recording from ${formatStartedAt(session.startedAt)}?\n\n` +
|
||||
`This permanently removes ${session.trackCount} audio file${session.trackCount === 1 ? '' : 's'}.`,
|
||||
);
|
||||
if (!ok) return;
|
||||
setBusy(session.sessionId);
|
||||
try {
|
||||
await recording.recoverSession({
|
||||
sessionDir: session.sessionDir,
|
||||
action: 'delete',
|
||||
});
|
||||
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
|
||||
} catch (err) {
|
||||
console.warn('Failed to delete recovered session:', err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = async (session: RecoverableSession) => {
|
||||
if (!recording) return;
|
||||
try {
|
||||
await recording.openFolder(session.sessionDir);
|
||||
} catch (err) {
|
||||
console.warn('Failed to open recovered folder:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && sessions.length === 0) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [isOpen, sessions.length]);
|
||||
|
||||
return (
|
||||
<Modal.Root isOpen={isOpen} onClose={dismiss} size="medium">
|
||||
<Modal.Header title="Recover voice recordings" onClose={dismiss} />
|
||||
<Modal.Content>
|
||||
<div className={styles.card}>
|
||||
<p className={styles.lead}>
|
||||
These voice recordings didn't finalise cleanly. The audio is
|
||||
intact up to the moment the app was interrupted. Choose what
|
||||
to do with each session — keeping a session just stamps the
|
||||
end time so it stops appearing in this list.
|
||||
</p>
|
||||
<div className={styles.list}>
|
||||
{sessions.map((session) => (
|
||||
<div key={session.sessionId} className={styles.row}>
|
||||
<div className={styles.rowMeta}>
|
||||
<span className={styles.rowPrimary}>
|
||||
{session.channelName ? `#${session.channelName}` : 'Voice call'}
|
||||
{' • '}
|
||||
{formatStartedAt(session.startedAt)}
|
||||
</span>
|
||||
<span className={styles.rowSecondary}>
|
||||
{session.trackCount} file
|
||||
{session.trackCount === 1 ? '' : 's'} ·{' '}
|
||||
{session.participantCount} participant
|
||||
{session.participantCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.rowActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionButton}
|
||||
onClick={() => handleOpen(session)}
|
||||
disabled={busy === session.sessionId}
|
||||
title="Open in file explorer"
|
||||
>
|
||||
<FolderOpen size={14} weight="bold" />
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionButton} ${styles.actionDanger}`}
|
||||
onClick={() => handleDelete(session)}
|
||||
disabled={busy === session.sessionId}
|
||||
title="Delete recording"
|
||||
>
|
||||
<Trash size={14} weight="bold" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionButton}
|
||||
onClick={() => handleKeep(session)}
|
||||
disabled={busy === session.sessionId}
|
||||
title="Keep this recording"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Button variant="secondary" size="sm" onClick={dismiss}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@@ -23,11 +23,14 @@ import {
|
||||
Desktop,
|
||||
MonitorPlay,
|
||||
PhoneX,
|
||||
Record,
|
||||
Waveform,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tooltip } from '@discord-clone/ui';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { RecordingIndicator } from './RecordingIndicator';
|
||||
import styles from './VoiceConnectionStatus.module.css';
|
||||
|
||||
/**
|
||||
@@ -97,6 +100,24 @@ export function VoiceConnectionStatus() {
|
||||
const voice = useVoice() as any;
|
||||
const navigate = useNavigate();
|
||||
const voiceSettings = useVoiceSettingsSnapshot();
|
||||
const platform = usePlatform() as any;
|
||||
const canRecord = !!platform?.features?.hasRecording;
|
||||
const isRecording = !!voice?.isRecording;
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
if (!canRecord) return;
|
||||
const ok = window.confirm(
|
||||
'Start recording this call?\n\n' +
|
||||
'Each participant will be saved to a separate audio file on your computer. ' +
|
||||
'You are responsible for obtaining consent from participants where required by law.',
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await voice?.startRecording?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to start recording:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!voice?.activeChannelId) return null;
|
||||
const state: string = voice.connectionState || 'disconnected';
|
||||
@@ -159,6 +180,19 @@ export function VoiceConnectionStatus() {
|
||||
{status.text}
|
||||
</button>
|
||||
<div className={styles.controls}>
|
||||
{canRecord && isConnected && !isRecording && (
|
||||
<Tooltip content="Record call" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlButton}
|
||||
onClick={handleStartRecording}
|
||||
aria-label="Record call"
|
||||
>
|
||||
<Record weight="fill" className={styles.icon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canRecord && isConnected && isRecording && <RecordingIndicator />}
|
||||
<Tooltip
|
||||
content={
|
||||
noiseSuppressionActive
|
||||
|
||||
Reference in New Issue
Block a user