feat: send screen recordings (#128)
* first pass implementation * remove screenrecord shortcut hint * refactor * fix: prevent mirror review of screen recording * feat: allow recording with webcam overlay * fix: review screen recording without clipped content * tidy keyboard hints consistent position * refactor: re-use one component for screen picker huddles and screen clips use same picker component now. We had to make sure that tailwind works for both of them. * fix: improve visibility of keyboard hints During compose video or screen recording, the keyboard hints were invisible if the content was super bright.
This commit was merged in pull request #128.
This commit is contained in:
@@ -76,6 +76,10 @@ const config: ForgeConfig = {
|
|||||||
name: 'huddle_window',
|
name: 'huddle_window',
|
||||||
config: 'vite.huddle.config.mts',
|
config: 'vite.huddle.config.mts',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'screen_record_window',
|
||||||
|
config: 'vite.screen-record.config.mts',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
// Fuses are used to enable/disable various Electron functionality
|
// Fuses are used to enable/disable various Electron functionality
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export const MediaPropertiesSchema = z.object({
|
|||||||
duration_ms: z.number(),
|
duration_ms: z.number(),
|
||||||
size_bytes: z.number(),
|
size_bytes: z.number(),
|
||||||
transcript: TranscriptSchema.optional(),
|
transcript: TranscriptSchema.optional(),
|
||||||
|
source: z.enum(["camera", "screen"]).optional(),
|
||||||
});
|
});
|
||||||
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
|
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
|
||||||
|
|
||||||
|
|||||||
+37
-15
@@ -1,30 +1,46 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
interface ScreenPickerProps {
|
interface ScreenSourcePickerProps {
|
||||||
|
title?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
getSources: () => Promise<ScreenSource[]>;
|
||||||
onSelect: (sourceId: string) => void;
|
onSelect: (sourceId: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScreenPicker({ onSelect, onCancel }: ScreenPickerProps) {
|
export function ScreenSourcePicker({
|
||||||
|
title = "Select a screen",
|
||||||
|
confirmLabel = "Select",
|
||||||
|
getSources,
|
||||||
|
onSelect,
|
||||||
|
onCancel,
|
||||||
|
}: ScreenSourcePickerProps) {
|
||||||
const [sources, setSources] = useState<ScreenSource[]>([]);
|
const [sources, setSources] = useState<ScreenSource[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronHuddle.getScreenSources().then((result) => {
|
getSources().then((result) => {
|
||||||
setSources(result);
|
setSources(result);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [getSources]);
|
||||||
|
|
||||||
const screens = sources.filter((s) => s.id.startsWith('screen:'));
|
// Auto-select if there's only one source
|
||||||
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
useEffect(() => {
|
||||||
|
if (!loading && sources.length === 1) {
|
||||||
|
setSelectedId(sources[0].id);
|
||||||
|
}
|
||||||
|
}, [loading, sources]);
|
||||||
|
|
||||||
|
const screens = sources.filter((s) => s.id.startsWith("screen:"));
|
||||||
|
const windows = sources.filter((s) => s.id.startsWith("window:"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
||||||
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
|
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
|
||||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||||
<h2 className="text-base font-medium text-zinc-100">Share your screen</h2>
|
<h2 className="text-base font-medium text-zinc-100">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-zinc-400 hover:text-zinc-200"
|
className="text-zinc-400 hover:text-zinc-200"
|
||||||
@@ -35,7 +51,9 @@ export function ScreenPicker({ onSelect, onCancel }: ScreenPickerProps) {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-center text-sm text-zinc-400">Loading sources…</p>
|
<p className="text-center text-sm text-zinc-400">
|
||||||
|
Loading sources...
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{screens.length > 0 && (
|
{screens.length > 0 && (
|
||||||
@@ -70,7 +88,7 @@ export function ScreenPicker({ onSelect, onCancel }: ScreenPickerProps) {
|
|||||||
onClick={() => selectedId && onSelect(selectedId)}
|
onClick={() => selectedId && onSelect(selectedId)}
|
||||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-40 disabled:hover:bg-blue-600"
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-40 disabled:hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
Share
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +109,9 @@ function SourceSection({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">{title}</h3>
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{sources.map((source) => (
|
{sources.map((source) => (
|
||||||
<button
|
<button
|
||||||
@@ -99,8 +119,8 @@ function SourceSection({
|
|||||||
onClick={() => onSelect(source.id)}
|
onClick={() => onSelect(source.id)}
|
||||||
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
||||||
selectedId === source.id
|
selectedId === source.id
|
||||||
? 'border-blue-500 bg-zinc-800'
|
? "border-blue-500 bg-zinc-800"
|
||||||
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
|
: "border-transparent bg-zinc-800/50 hover:border-zinc-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -108,7 +128,9 @@ function SourceSection({
|
|||||||
alt={source.name}
|
alt={source.name}
|
||||||
className="aspect-video w-full object-cover"
|
className="aspect-video w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">{source.name}</p>
|
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">
|
||||||
|
{source.name}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
Vendored
+10
@@ -30,6 +30,16 @@ declare global {
|
|||||||
onStop: (callback: () => void) => () => void;
|
onStop: (callback: () => void) => () => void;
|
||||||
onNavigate: (callback: (data: { networkId: string; streamId: string }) => void) => () => void;
|
onNavigate: (callback: (data: { networkId: string; streamId: string }) => void) => () => void;
|
||||||
};
|
};
|
||||||
|
electronScreen: {
|
||||||
|
getScreenSources: () => Promise<ScreenSource[]>;
|
||||||
|
startRecordingWindow: (data: { includeCamera: boolean }) => void;
|
||||||
|
stopRecordingWindow: () => void;
|
||||||
|
onStopRequested: (callback: () => void) => () => void;
|
||||||
|
};
|
||||||
|
electronScreenRecord: {
|
||||||
|
stop: () => void;
|
||||||
|
onInit: (callback: (data: { includeCamera: boolean }) => void) => () => void;
|
||||||
|
};
|
||||||
electronLink: {
|
electronLink: {
|
||||||
fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
|
fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
|
||||||
openExternal: (url: string) => Promise<void>;
|
openExternal: (url: string) => Promise<void>;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { toast } from "sonner";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||||
import { useRecorder } from "@/features/compose/use-recorder";
|
import { useRecorder } from "@/features/compose/use-recorder";
|
||||||
|
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
|
||||||
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
||||||
import type { ParticlePath } from "@/lib/particle-path";
|
import type { ParticlePath } from "@/lib/particle-path";
|
||||||
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
||||||
|
import { ScreenSourcePicker } from "@/components/screen-source-picker";
|
||||||
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
||||||
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
@@ -15,7 +17,9 @@ import { createImageThumbnail } from "@/lib/image-thumbnail";
|
|||||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
||||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||||
|
|
||||||
type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
||||||
|
|
||||||
|
type RecordingSource = "media" | "screen";
|
||||||
|
|
||||||
interface ComposeOverlayProps {
|
interface ComposeOverlayProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
@@ -50,6 +54,7 @@ export function ComposeOverlay({
|
|||||||
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
|
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
|
||||||
|
|
||||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||||
|
const [recordingSource, setRecordingSource] = useState<RecordingSource>("media");
|
||||||
|
|
||||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
@@ -61,6 +66,8 @@ export function ComposeOverlay({
|
|||||||
const recordStartRef = useRef(0);
|
const recordStartRef = useRef(0);
|
||||||
const disabledRef = useRef(disabled);
|
const disabledRef = useRef(disabled);
|
||||||
disabledRef.current = disabled;
|
disabledRef.current = disabled;
|
||||||
|
const recordingSourceRef = useRef(recordingSource);
|
||||||
|
recordingSourceRef.current = recordingSource;
|
||||||
|
|
||||||
const setStepSync = useCallback((next: ComposeStep) => {
|
const setStepSync = useCallback((next: ComposeStep) => {
|
||||||
stepRef.current = next;
|
stepRef.current = next;
|
||||||
@@ -86,6 +93,7 @@ export function ComposeOverlay({
|
|||||||
setReviewBlob(null);
|
setReviewBlob(null);
|
||||||
setReviewDurationMs(0);
|
setReviewDurationMs(0);
|
||||||
setReviewMimeType(null);
|
setReviewMimeType(null);
|
||||||
|
setRecordingSource("media");
|
||||||
setAttachments((prev) => {
|
setAttachments((prev) => {
|
||||||
revokeAttachmentThumbnails(prev);
|
revokeAttachmentThumbnails(prev);
|
||||||
return [];
|
return [];
|
||||||
@@ -151,6 +159,24 @@ export function ComposeOverlay({
|
|||||||
onError: (message) => setError(message),
|
onError: (message) => setError(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
startRecording: startScreenRecording,
|
||||||
|
stopRecording: stopScreenRecording,
|
||||||
|
cancelRecording: cancelScreenRecording,
|
||||||
|
} = useScreenRecorder({
|
||||||
|
mode: recordingMode,
|
||||||
|
onFinish: (blob, durationMs, mimeType) => {
|
||||||
|
setStepSync("reviewing");
|
||||||
|
setReviewBlob(blob);
|
||||||
|
setReviewDurationMs(durationMs);
|
||||||
|
setReviewMimeType(mimeType);
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
setError(message);
|
||||||
|
cancel();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// --- Submission ---
|
// --- Submission ---
|
||||||
|
|
||||||
const uploadMedia = useCallback(
|
const uploadMedia = useCallback(
|
||||||
@@ -259,6 +285,7 @@ export function ComposeOverlay({
|
|||||||
reviewMimeType,
|
reviewMimeType,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isAudioOnly = reviewMimeType.startsWith("audio/");
|
||||||
particleId = await createParticle.mutateAsync({
|
particleId = await createParticle.mutateAsync({
|
||||||
path,
|
path,
|
||||||
type: "media",
|
type: "media",
|
||||||
@@ -267,6 +294,9 @@ export function ComposeOverlay({
|
|||||||
mime_type: reviewMimeType,
|
mime_type: reviewMimeType,
|
||||||
duration_ms: reviewDurationMs,
|
duration_ms: reviewDurationMs,
|
||||||
size_bytes,
|
size_bytes,
|
||||||
|
...(!isAudioOnly && {
|
||||||
|
source: recordingSource === "screen" ? "screen" as const : "camera" as const,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
createdByHumanId: userId,
|
createdByHumanId: userId,
|
||||||
});
|
});
|
||||||
@@ -283,6 +313,7 @@ export function ComposeOverlay({
|
|||||||
reviewBlob,
|
reviewBlob,
|
||||||
reviewMimeType,
|
reviewMimeType,
|
||||||
reviewDurationMs,
|
reviewDurationMs,
|
||||||
|
recordingSource,
|
||||||
createParticle,
|
createParticle,
|
||||||
uploadMedia,
|
uploadMedia,
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -327,7 +358,7 @@ export function ComposeOverlay({
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const currentStep = stepRef.current;
|
const currentStep = stepRef.current;
|
||||||
|
|
||||||
if (currentStep === "typing" || currentStep === "configuring") {
|
if (currentStep === "typing" || currentStep === "configuring" || currentStep === "picking") {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cancel();
|
cancel();
|
||||||
@@ -347,7 +378,7 @@ export function ComposeOverlay({
|
|||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case "idle": {
|
case "idle": {
|
||||||
if (disabledRef.current) {
|
if (disabledRef.current) {
|
||||||
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T") {
|
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toast.info("This stream is closed");
|
toast.info("This stream is closed");
|
||||||
}
|
}
|
||||||
@@ -356,8 +387,13 @@ export function ComposeOverlay({
|
|||||||
if (e.key === "`" && !e.repeat) {
|
if (e.key === "`" && !e.repeat) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
recordStartRef.current = Date.now();
|
recordStartRef.current = Date.now();
|
||||||
|
setRecordingSource("media");
|
||||||
setStepSync("recording");
|
setStepSync("recording");
|
||||||
startRecording();
|
startRecording();
|
||||||
|
} else if (e.key === "s" || e.key === "S") {
|
||||||
|
e.preventDefault();
|
||||||
|
setRecordingSource("screen");
|
||||||
|
setStepSync("picking");
|
||||||
} else if (e.key === "t" || e.key === "T") {
|
} else if (e.key === "t" || e.key === "T") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setStepSync("typing");
|
setStepSync("typing");
|
||||||
@@ -370,9 +406,17 @@ export function ComposeOverlay({
|
|||||||
// Second tap stops recording (toggle mode)
|
// Second tap stops recording (toggle mode)
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
stopRecording();
|
stopRecording();
|
||||||
|
} else if ((e.key === "s" || e.key === "S") && recordingSourceRef.current === "screen") {
|
||||||
|
// S stops screen recording when main window is focused
|
||||||
|
e.preventDefault();
|
||||||
|
stopScreenRecording();
|
||||||
} else if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
} else if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cancelRecording();
|
if (recordingSourceRef.current === "screen") {
|
||||||
|
cancelScreenRecording();
|
||||||
|
} else {
|
||||||
|
cancelRecording();
|
||||||
|
}
|
||||||
cancel();
|
cancel();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -381,7 +425,11 @@ export function ComposeOverlay({
|
|||||||
case "reviewing": {
|
case "reviewing": {
|
||||||
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cancelRecording();
|
if (recordingSourceRef.current === "screen") {
|
||||||
|
cancelScreenRecording();
|
||||||
|
} else {
|
||||||
|
cancelRecording();
|
||||||
|
}
|
||||||
cancel();
|
cancel();
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -397,7 +445,7 @@ export function ComposeOverlay({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
if (stepRef.current === "recording" && e.key === "`") {
|
if (stepRef.current === "recording" && e.key === "`" && recordingSourceRef.current === "media") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Only stop on release if held long enough (hold-to-record mode).
|
// Only stop on release if held long enough (hold-to-record mode).
|
||||||
// Quick taps are handled by the second keydown (toggle mode).
|
// Quick taps are handled by the second keydown (toggle mode).
|
||||||
@@ -413,7 +461,17 @@ export function ComposeOverlay({
|
|||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
};
|
};
|
||||||
}, [targetPath, startRecording, stopRecording, cancelRecording, cancel, setStepSync]);
|
}, [targetPath, startRecording, stopRecording, cancelRecording, startScreenRecording, stopScreenRecording, cancelScreenRecording, cancel, setStepSync]);
|
||||||
|
|
||||||
|
// --- Screen source selection handler ---
|
||||||
|
|
||||||
|
const handleScreenSourceSelected = useCallback(
|
||||||
|
(sourceId: string) => {
|
||||||
|
setStepSync("recording");
|
||||||
|
startScreenRecording(sourceId);
|
||||||
|
},
|
||||||
|
[setStepSync, startScreenRecording],
|
||||||
|
);
|
||||||
|
|
||||||
// --- Render ---
|
// --- Render ---
|
||||||
|
|
||||||
@@ -425,7 +483,16 @@ export function ComposeOverlay({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(step === "recording" || step === "reviewing") && (
|
{step === "picking" && (
|
||||||
|
<ScreenSourcePicker
|
||||||
|
title="Record your screen"
|
||||||
|
confirmLabel="Record"
|
||||||
|
getSources={window.electronScreen.getScreenSources}
|
||||||
|
onSelect={handleScreenSourceSelected}
|
||||||
|
onCancel={cancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(step === "recording" || step === "reviewing") && recordingSource === "media" && (
|
||||||
<RecordingOverlay
|
<RecordingOverlay
|
||||||
step={step}
|
step={step}
|
||||||
mediaStream={mediaStream}
|
mediaStream={mediaStream}
|
||||||
@@ -438,6 +505,25 @@ export function ComposeOverlay({
|
|||||||
onAddFiles={openFilePicker}
|
onAddFiles={openFilePicker}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
dropZoneProps={dropZoneProps}
|
dropZoneProps={dropZoneProps}
|
||||||
|
mirror={true}
|
||||||
|
objectFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === "reviewing" && recordingSource === "screen" && reviewBlob && (
|
||||||
|
<RecordingOverlay
|
||||||
|
step="reviewing"
|
||||||
|
mediaStream={null}
|
||||||
|
recordingMode="video"
|
||||||
|
reviewBlob={reviewBlob}
|
||||||
|
error={error}
|
||||||
|
onClose={cancel}
|
||||||
|
attachments={attachments}
|
||||||
|
onRemoveAttachment={removeAttachment}
|
||||||
|
onAddFiles={openFilePicker}
|
||||||
|
isDragging={isDragging}
|
||||||
|
dropZoneProps={dropZoneProps}
|
||||||
|
mirror={false}
|
||||||
|
objectFit="contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === "typing" && (
|
{step === "typing" && (
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ interface RecordingOverlayProps {
|
|||||||
onDragLeave: (e: React.DragEvent) => void;
|
onDragLeave: (e: React.DragEvent) => void;
|
||||||
onDrop: (e: React.DragEvent) => void;
|
onDrop: (e: React.DragEvent) => void;
|
||||||
};
|
};
|
||||||
|
/** Mirror the video horizontally. Defaults to true (selfie-view for webcam). */
|
||||||
|
mirror?: boolean;
|
||||||
|
/** How video fills its container. Defaults to "cover". Use "contain" for screen recordings. */
|
||||||
|
objectFit?: "cover" | "contain";
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordingTimer() {
|
function RecordingTimer() {
|
||||||
@@ -52,9 +56,13 @@ function RecordingTimer() {
|
|||||||
function ReviewPlayback({
|
function ReviewPlayback({
|
||||||
blob,
|
blob,
|
||||||
isVideo,
|
isVideo,
|
||||||
|
mirror = true,
|
||||||
|
objectFit = "cover",
|
||||||
}: {
|
}: {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
|
mirror?: boolean;
|
||||||
|
objectFit?: "cover" | "contain";
|
||||||
}) {
|
}) {
|
||||||
const urlRef = useRef<string | null>(null);
|
const urlRef = useRef<string | null>(null);
|
||||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
@@ -82,7 +90,7 @@ function ReviewPlayback({
|
|||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
className="absolute inset-0 h-full w-full -scale-x-100 object-cover"
|
className={`absolute inset-0 h-full w-full ${objectFit === "contain" ? "object-contain" : "object-cover"}${mirror ? " -scale-x-100" : ""}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,6 +127,8 @@ export function RecordingOverlay({
|
|||||||
onAddFiles,
|
onAddFiles,
|
||||||
isDragging,
|
isDragging,
|
||||||
dropZoneProps,
|
dropZoneProps,
|
||||||
|
mirror = true,
|
||||||
|
objectFit = "cover",
|
||||||
}: RecordingOverlayProps) {
|
}: RecordingOverlayProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
||||||
@@ -176,9 +186,16 @@ export function RecordingOverlay({
|
|||||||
<ReviewPlayback
|
<ReviewPlayback
|
||||||
blob={reviewBlob}
|
blob={reviewBlob}
|
||||||
isVideo={recordingMode === "video"}
|
isVideo={recordingMode === "video"}
|
||||||
|
mirror={mirror}
|
||||||
|
objectFit={objectFit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom gradient scrim for keyboard hint readability */}
|
||||||
|
{(isRecording || isReviewing) && !isLoading && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top center: recording indicator */}
|
{/* Top center: recording indicator */}
|
||||||
<div className="absolute top-8 z-10">
|
<div className="absolute top-8 z-10">
|
||||||
{isRecording && !isLoading ? (
|
{isRecording && !isLoading ? (
|
||||||
@@ -199,7 +216,7 @@ export function RecordingOverlay({
|
|||||||
|
|
||||||
{/* Bottom center: keyboard hints */}
|
{/* Bottom center: keyboard hints */}
|
||||||
{isRecording && !isLoading && (
|
{isRecording && !isLoading && (
|
||||||
<div className="absolute bottom-8 z-10 flex items-center gap-4 text-sm text-white/50">
|
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
|
||||||
<span>
|
<span>
|
||||||
Release{" "}
|
Release{" "}
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
@@ -217,7 +234,7 @@ export function RecordingOverlay({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isReviewing && (
|
{isReviewing && (
|
||||||
<div className="absolute bottom-8 z-10 flex flex-col items-center gap-3">
|
<div className="absolute bottom-4 z-10 flex flex-col items-center gap-3">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<AttachmentStrip
|
<AttachmentStrip
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import type { RecordingMode } from "@/stores/media-settings-store";
|
||||||
|
|
||||||
|
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||||
|
const VIDEO_FALLBACK_MIME = "video/webm";
|
||||||
|
|
||||||
|
function getScreenMime(): string {
|
||||||
|
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
|
||||||
|
? VIDEO_PREFERRED_MIME
|
||||||
|
: VIDEO_FALLBACK_MIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Canvas compositor — overlays webcam as a circular PiP on the screen feed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Compositor {
|
||||||
|
/** Composited video stream (screen + optional webcam bubble). */
|
||||||
|
stream: MediaStream;
|
||||||
|
/** Tear down the animation loop and video elements. */
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCompositor(
|
||||||
|
screenStream: MediaStream,
|
||||||
|
cameraStream: MediaStream | null,
|
||||||
|
): Compositor {
|
||||||
|
const screenTrack = screenStream.getVideoTracks()[0];
|
||||||
|
const settings = screenTrack.getSettings();
|
||||||
|
const width = settings.width ?? 1920;
|
||||||
|
const height = settings.height ?? 1080;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
// Hidden video elements used as frame sources
|
||||||
|
const screenVideo = document.createElement("video");
|
||||||
|
screenVideo.srcObject = screenStream;
|
||||||
|
screenVideo.muted = true;
|
||||||
|
screenVideo.playsInline = true;
|
||||||
|
screenVideo.play();
|
||||||
|
|
||||||
|
let cameraVideo: HTMLVideoElement | null = null;
|
||||||
|
if (cameraStream) {
|
||||||
|
cameraVideo = document.createElement("video");
|
||||||
|
cameraVideo.srcObject = cameraStream;
|
||||||
|
cameraVideo.muted = true;
|
||||||
|
cameraVideo.playsInline = true;
|
||||||
|
cameraVideo.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
let animId = 0;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
// Screen — full canvas
|
||||||
|
ctx.drawImage(screenVideo, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Webcam — circular bubble in bottom-left
|
||||||
|
if (cameraVideo && cameraVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||||
|
const bubbleSize = Math.round(Math.min(width, height) * 0.18);
|
||||||
|
const margin = Math.round(bubbleSize * 0.3);
|
||||||
|
const cx = margin + bubbleSize / 2;
|
||||||
|
const cy = height - margin - bubbleSize / 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, bubbleSize / 2, 0, Math.PI * 2);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
// Crop camera to square centre, mirror horizontally
|
||||||
|
const vw = cameraVideo.videoWidth || 1;
|
||||||
|
const vh = cameraVideo.videoHeight || 1;
|
||||||
|
const side = Math.min(vw, vh);
|
||||||
|
const sx = (vw - side) / 2;
|
||||||
|
const sy = (vh - side) / 2;
|
||||||
|
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.scale(-1, 1); // horizontal flip
|
||||||
|
ctx.drawImage(
|
||||||
|
cameraVideo,
|
||||||
|
sx, sy, side, side,
|
||||||
|
-bubbleSize / 2, -bubbleSize / 2, bubbleSize, bubbleSize,
|
||||||
|
);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Subtle ring around the bubble
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, bubbleSize / 2, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.25)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: canvas.captureStream(30),
|
||||||
|
stop: () => {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
screenVideo.pause();
|
||||||
|
screenVideo.srcObject = null;
|
||||||
|
if (cameraVideo) {
|
||||||
|
cameraVideo.pause();
|
||||||
|
cameraVideo.srcObject = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface UseScreenRecorderOptions {
|
||||||
|
/** "video" = screen + webcam overlay + mic. "audio" = screen + mic only. */
|
||||||
|
mode: RecordingMode;
|
||||||
|
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages screen recording via Electron's desktopCapturer.
|
||||||
|
*
|
||||||
|
* When mode is "video", captures screen video composited with a webcam
|
||||||
|
* overlay (via Canvas) plus mic audio.
|
||||||
|
* When mode is "audio", captures screen video with mic audio only.
|
||||||
|
*/
|
||||||
|
export function useScreenRecorder({
|
||||||
|
mode,
|
||||||
|
onFinish,
|
||||||
|
onError,
|
||||||
|
}: UseScreenRecorderOptions) {
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
const micStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
const cameraStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
const compositorRef = useRef<Compositor | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const startTimeRef = useRef<number>(0);
|
||||||
|
const cleanupIpcRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const onFinishRef = useRef(onFinish);
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
useEffect(() => {
|
||||||
|
onFinishRef.current = onFinish;
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
});
|
||||||
|
|
||||||
|
const modeRef = useRef(mode);
|
||||||
|
modeRef.current = mode;
|
||||||
|
|
||||||
|
const stopAllTracks = useCallback(() => {
|
||||||
|
compositorRef.current?.stop();
|
||||||
|
compositorRef.current = null;
|
||||||
|
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
micStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
cameraStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
screenStreamRef.current = null;
|
||||||
|
micStreamRef.current = null;
|
||||||
|
cameraStreamRef.current = null;
|
||||||
|
recorderRef.current = null;
|
||||||
|
chunksRef.current = [];
|
||||||
|
cleanupIpcRef.current?.();
|
||||||
|
cleanupIpcRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRecording = useCallback(
|
||||||
|
async (sourceId: string) => {
|
||||||
|
try {
|
||||||
|
const includeCamera = modeRef.current === "video";
|
||||||
|
|
||||||
|
// 1. Screen video
|
||||||
|
const screenStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: "desktop",
|
||||||
|
chromeMediaSourceId: sourceId,
|
||||||
|
},
|
||||||
|
} as unknown as MediaTrackConstraints,
|
||||||
|
});
|
||||||
|
screenStreamRef.current = screenStream;
|
||||||
|
|
||||||
|
// 2. Mic audio
|
||||||
|
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
micStreamRef.current = micStream;
|
||||||
|
|
||||||
|
// 3. Camera (only in video mode)
|
||||||
|
let cameraStream: MediaStream | null = null;
|
||||||
|
if (includeCamera) {
|
||||||
|
try {
|
||||||
|
cameraStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { aspectRatio: { ideal: 1 }, width: { ideal: 320 } },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
cameraStreamRef.current = cameraStream;
|
||||||
|
} catch {
|
||||||
|
// Camera unavailable — proceed without it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Composite screen + camera via canvas
|
||||||
|
const compositor = createCompositor(screenStream, cameraStream);
|
||||||
|
compositorRef.current = compositor;
|
||||||
|
|
||||||
|
// 5. Combine composited video + mic audio
|
||||||
|
const combined = new MediaStream([
|
||||||
|
...compositor.stream.getVideoTracks(),
|
||||||
|
...micStream.getAudioTracks(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
chunksRef.current = [];
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
|
||||||
|
const mime = getScreenMime();
|
||||||
|
const recorder = new MediaRecorder(combined, { mimeType: mime });
|
||||||
|
recorderRef.current = recorder;
|
||||||
|
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const durationMs = Date.now() - startTimeRef.current;
|
||||||
|
const blob = new Blob(chunksRef.current, { type: mime });
|
||||||
|
stopAllTracks();
|
||||||
|
window.electronScreen.stopRecordingWindow();
|
||||||
|
|
||||||
|
if (blob.size > 0) {
|
||||||
|
onFinishRef.current(blob, durationMs, mime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.start();
|
||||||
|
|
||||||
|
// 6. Show floating control window
|
||||||
|
window.electronScreen.startRecordingWindow({ includeCamera });
|
||||||
|
|
||||||
|
// 7. Listen for stop from floating window
|
||||||
|
cleanupIpcRef.current = window.electronScreen.onStopRequested(() => {
|
||||||
|
if (recorderRef.current?.state === "recording") {
|
||||||
|
recorderRef.current.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
stopAllTracks();
|
||||||
|
window.electronScreen.stopRecordingWindow();
|
||||||
|
onErrorRef.current(
|
||||||
|
err instanceof Error ? err.message : "Failed to start screen recording",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stopAllTracks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (recorderRef.current?.state === "recording") {
|
||||||
|
recorderRef.current.stop();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelRecording = useCallback(() => {
|
||||||
|
if (recorderRef.current) {
|
||||||
|
recorderRef.current.ondataavailable = null;
|
||||||
|
recorderRef.current.onstop = null;
|
||||||
|
if (recorderRef.current.state === "recording") {
|
||||||
|
recorderRef.current.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopAllTracks();
|
||||||
|
window.electronScreen.stopRecordingWindow();
|
||||||
|
}, [stopAllTracks]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopAllTracks();
|
||||||
|
window.electronScreen.stopRecordingWindow();
|
||||||
|
};
|
||||||
|
}, [stopAllTracks]);
|
||||||
|
|
||||||
|
return { startRecording, stopRecording, cancelRecording };
|
||||||
|
}
|
||||||
@@ -147,7 +147,7 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
|||||||
playsInline
|
playsInline
|
||||||
onEnded={onEnded}
|
onEnded={onEnded}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
className="h-full w-full object-cover"
|
className={`h-full w-full ${particle.properties.source === "screen" ? "object-contain bg-black" : "object-cover"}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{transcript && (
|
{transcript && (
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
|||||||
label: "Compose",
|
label: "Compose",
|
||||||
bindings: [
|
bindings: [
|
||||||
{ keys: ["Hold", "`"], description: "Reply" },
|
{ keys: ["Hold", "`"], description: "Reply" },
|
||||||
|
{ keys: ["S"], description: "Screen record" },
|
||||||
{ keys: ["T"], description: "Text compose" },
|
{ keys: ["T"], description: "Text compose" },
|
||||||
{ keys: ["H"], description: "Join huddle" },
|
{ keys: ["H"], description: "Join huddle" },
|
||||||
],
|
],
|
||||||
@@ -435,6 +436,9 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
disabled={streamParticle.status === "closed"}
|
disabled={streamParticle.status === "closed"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Bottom gradient safe zone for keyboard hints */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
|
||||||
|
|
||||||
{/* BottomBar */}
|
{/* BottomBar */}
|
||||||
<BottomBar
|
<BottomBar
|
||||||
visible={controlsVisible}
|
visible={controlsVisible}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from '@livekit/components-react';
|
} from '@livekit/components-react';
|
||||||
import { RoomEvent, Track } from 'livekit-client';
|
import { RoomEvent, Track } from 'livekit-client';
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { ScreenPicker } from './ScreenPicker';
|
import { ScreenSourcePicker } from '@/components/screen-source-picker';
|
||||||
|
|
||||||
export function HuddleApp() {
|
export function HuddleApp() {
|
||||||
const [connection, setConnection] = useState<{ token: string; serverUrl: string } | null>(null);
|
const [connection, setConnection] = useState<{ token: string; serverUrl: string } | null>(null);
|
||||||
@@ -198,7 +198,10 @@ function HuddleContent() {
|
|||||||
<RoomAudioRenderer />
|
<RoomAudioRenderer />
|
||||||
<StartAudio label="Allow audio" />
|
<StartAudio label="Allow audio" />
|
||||||
{showPicker && (
|
{showPicker && (
|
||||||
<ScreenPicker
|
<ScreenSourcePicker
|
||||||
|
title="Share your screen"
|
||||||
|
confirmLabel="Share"
|
||||||
|
getSources={window.electronHuddle.getScreenSources}
|
||||||
onSelect={handleScreenShare}
|
onSelect={handleScreenShare}
|
||||||
onCancel={() => setShowPicker(false)}
|
onCancel={() => setShowPicker(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ if (process.platform === 'darwin' && !app.isPackaged) {
|
|||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let autoplayWindow: BrowserWindow | null = null;
|
let autoplayWindow: BrowserWindow | null = null;
|
||||||
let huddleWindow: BrowserWindow | null = null;
|
let huddleWindow: BrowserWindow | null = null;
|
||||||
|
let screenRecordWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
const createWindow = () => {
|
const createWindow = () => {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -121,6 +122,43 @@ function positionAutoplayWindow() {
|
|||||||
autoplayWindow.setPosition(width - winW - 16, 16);
|
autoplayWindow.setPosition(width - winW - 16, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createScreenRecordWindow = () => {
|
||||||
|
if (screenRecordWindow) return;
|
||||||
|
|
||||||
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||||
|
const winW = 240;
|
||||||
|
const winH = 48;
|
||||||
|
|
||||||
|
screenRecordWindow = new BrowserWindow({
|
||||||
|
width: winW,
|
||||||
|
height: winH,
|
||||||
|
x: Math.round((width - winW) / 2),
|
||||||
|
y: height - winH - 32,
|
||||||
|
resizable: false,
|
||||||
|
frame: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
focusable: true,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
screenRecordWindow.setVisibleOnAllWorkspaces(true);
|
||||||
|
|
||||||
|
if (SCREEN_RECORD_WINDOW_VITE_DEV_SERVER_URL) {
|
||||||
|
screenRecordWindow.loadURL(SCREEN_RECORD_WINDOW_VITE_DEV_SERVER_URL);
|
||||||
|
} else {
|
||||||
|
screenRecordWindow.loadFile(
|
||||||
|
path.join(__dirname, `../renderer/${SCREEN_RECORD_WINDOW_VITE_NAME}/index.html`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
screenRecordWindow.on('closed', () => {
|
||||||
|
screenRecordWindow = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Window control IPC handlers
|
// Window control IPC handlers
|
||||||
ipcMain.on('window:minimize', (event) => {
|
ipcMain.on('window:minimize', (event) => {
|
||||||
BrowserWindow.fromWebContents(event.sender)?.minimize();
|
BrowserWindow.fromWebContents(event.sender)?.minimize();
|
||||||
@@ -170,6 +208,41 @@ ipcMain.handle('screen:get-sources', async () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Screen recording IPC handlers
|
||||||
|
ipcMain.on('screen-record:start', (_event, data: { includeCamera: boolean }) => {
|
||||||
|
createScreenRecordWindow();
|
||||||
|
if (!screenRecordWindow) return;
|
||||||
|
|
||||||
|
// Resize based on whether camera preview is shown
|
||||||
|
const winW = data.includeCamera ? 200 : 240;
|
||||||
|
const winH = data.includeCamera ? 176 : 48;
|
||||||
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||||
|
screenRecordWindow.setSize(winW, winH);
|
||||||
|
screenRecordWindow.setPosition(
|
||||||
|
Math.round((width - winW) / 2),
|
||||||
|
height - winH - 32,
|
||||||
|
);
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
screenRecordWindow?.webContents.send('screen-record:init', data);
|
||||||
|
screenRecordWindow?.showInactive();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (screenRecordWindow.webContents.isLoading()) {
|
||||||
|
screenRecordWindow.webContents.once('did-finish-load', send);
|
||||||
|
} else {
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.on('screen-record:stop', () => {
|
||||||
|
mainWindow?.webContents.send('screen-record:stopped');
|
||||||
|
screenRecordWindow?.close();
|
||||||
|
mainWindow?.focus();
|
||||||
|
});
|
||||||
|
ipcMain.on('screen-record:cancel', () => {
|
||||||
|
screenRecordWindow?.close();
|
||||||
|
});
|
||||||
|
|
||||||
// Autoplay IPC handlers
|
// Autoplay IPC handlers
|
||||||
ipcMain.on('autoplay:play', (_event, payload) => {
|
ipcMain.on('autoplay:play', (_event, payload) => {
|
||||||
if (!autoplayWindow) createAutoplayWindow();
|
if (!autoplayWindow) createAutoplayWindow();
|
||||||
|
|||||||
@@ -41,6 +41,26 @@ contextBridge.exposeInMainWorld('electronAutoplay', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronScreen', {
|
||||||
|
getScreenSources: () => ipcRenderer.invoke('screen:get-sources'),
|
||||||
|
startRecordingWindow: (data: { includeCamera: boolean }) => ipcRenderer.send('screen-record:start', data),
|
||||||
|
stopRecordingWindow: () => ipcRenderer.send('screen-record:cancel'),
|
||||||
|
onStopRequested: (callback: () => void) => {
|
||||||
|
const handler = () => callback();
|
||||||
|
ipcRenderer.on('screen-record:stopped', handler);
|
||||||
|
return () => { ipcRenderer.removeListener('screen-record:stopped', handler); };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronScreenRecord', {
|
||||||
|
stop: () => ipcRenderer.send('screen-record:stop'),
|
||||||
|
onInit: (callback: (data: { includeCamera: boolean }) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, data: { includeCamera: boolean }) => callback(data);
|
||||||
|
ipcRenderer.on('screen-record:init', handler);
|
||||||
|
return () => { ipcRenderer.removeListener('screen-record:init', handler); };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronLink', {
|
contextBridge.exposeInMainWorld('electronLink', {
|
||||||
fetchMetadata: (url: string) => ipcRenderer.invoke('link:fetch-metadata', url),
|
fetchMetadata: (url: string) => ipcRenderer.invoke('link:fetch-metadata', url),
|
||||||
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
|
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Square } from "lucide-react";
|
||||||
|
|
||||||
|
export function ScreenRecordControlApp() {
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const [includeCamera, setIncludeCamera] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
// Listen for init data from main process
|
||||||
|
useEffect(() => {
|
||||||
|
return window.electronScreenRecord.onInit((data) => {
|
||||||
|
setIncludeCamera(data.includeCamera);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Acquire webcam for preview (independent from the recording capture)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!includeCamera) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({ video: { aspectRatio: { ideal: 1 }, width: { ideal: 160 } }, audio: false })
|
||||||
|
.then((stream) => {
|
||||||
|
if (cancelled) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamRef.current = stream;
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Camera unavailable — just don't show preview
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
};
|
||||||
|
}, [includeCamera]);
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setElapsed((prev) => prev + 1), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const minutes = Math.floor(elapsed / 60);
|
||||||
|
const seconds = elapsed % 60;
|
||||||
|
const display = `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
window.electronScreenRecord.stop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen flex-col items-center justify-center gap-2 bg-zinc-900 px-4 py-3">
|
||||||
|
{/* Webcam preview */}
|
||||||
|
{includeCamera && (
|
||||||
|
<div className="relative h-24 w-24 overflow-hidden rounded-full bg-zinc-800">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
className="h-full w-full -scale-x-100 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
||||||
|
<span className="font-mono text-sm text-white/80">{display}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
className="flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-red-500"
|
||||||
|
>
|
||||||
|
<Square className="size-3 fill-current" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>llink - Recording</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./renderer.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { ScreenRecordControlApp } from './ScreenRecordControlApp';
|
||||||
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById('root')!);
|
||||||
|
root.render(<ScreenRecordControlApp />);
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
/* Ensure Tailwind scans shared components/features used by secondary windows
|
||||||
|
(huddle, screen_record, autoplay) whose vite root is a subdirectory. */
|
||||||
|
@source "../components";
|
||||||
|
@source "../features";
|
||||||
|
@source "../lib";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
Vendored
+2
@@ -6,3 +6,5 @@ declare const AUTOPLAY_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
|
|||||||
declare const AUTOPLAY_WINDOW_VITE_NAME: string;
|
declare const AUTOPLAY_WINDOW_VITE_NAME: string;
|
||||||
declare const HUDDLE_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
|
declare const HUDDLE_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
|
||||||
declare const HUDDLE_WINDOW_VITE_NAME: string;
|
declare const HUDDLE_WINDOW_VITE_NAME: string;
|
||||||
|
declare const SCREEN_RECORD_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
|
||||||
|
declare const SCREEN_RECORD_WINDOW_VITE_NAME: string;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import path from "path"
|
||||||
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config
|
||||||
|
export default defineConfig({
|
||||||
|
root: path.resolve(__dirname, './src/screen_record_window'),
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(__dirname, '.vite/renderer/screen_record_window'),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
allow: [path.resolve(__dirname)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user