first pass implementation
This commit is contained in:
@@ -3,9 +3,11 @@ import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||
import { useRecorder } from "@/features/compose/use-recorder";
|
||||
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
|
||||
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
||||
import { ScreenSourcePicker } from "@/features/compose/screen-source-picker";
|
||||
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
||||
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
||||
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 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 {
|
||||
networkId: string;
|
||||
@@ -50,6 +54,7 @@ export function ComposeOverlay({
|
||||
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
|
||||
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [recordingSource, setRecordingSource] = useState<RecordingSource>("media");
|
||||
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
@@ -61,6 +66,8 @@ export function ComposeOverlay({
|
||||
const recordStartRef = useRef(0);
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
const recordingSourceRef = useRef(recordingSource);
|
||||
recordingSourceRef.current = recordingSource;
|
||||
|
||||
const setStepSync = useCallback((next: ComposeStep) => {
|
||||
stepRef.current = next;
|
||||
@@ -86,6 +93,7 @@ export function ComposeOverlay({
|
||||
setReviewBlob(null);
|
||||
setReviewDurationMs(0);
|
||||
setReviewMimeType(null);
|
||||
setRecordingSource("media");
|
||||
setAttachments((prev) => {
|
||||
revokeAttachmentThumbnails(prev);
|
||||
return [];
|
||||
@@ -151,6 +159,23 @@ export function ComposeOverlay({
|
||||
onError: (message) => setError(message),
|
||||
});
|
||||
|
||||
const {
|
||||
startRecording: startScreenRecording,
|
||||
stopRecording: stopScreenRecording,
|
||||
cancelRecording: cancelScreenRecording,
|
||||
} = useScreenRecorder({
|
||||
onFinish: (blob, durationMs, mimeType) => {
|
||||
setStepSync("reviewing");
|
||||
setReviewBlob(blob);
|
||||
setReviewDurationMs(durationMs);
|
||||
setReviewMimeType(mimeType);
|
||||
},
|
||||
onError: (message) => {
|
||||
setError(message);
|
||||
cancel();
|
||||
},
|
||||
});
|
||||
|
||||
// --- Submission ---
|
||||
|
||||
const uploadMedia = useCallback(
|
||||
@@ -327,7 +352,7 @@ export function ComposeOverlay({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const currentStep = stepRef.current;
|
||||
|
||||
if (currentStep === "typing" || currentStep === "configuring") {
|
||||
if (currentStep === "typing" || currentStep === "configuring" || currentStep === "picking") {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
@@ -347,7 +372,7 @@ export function ComposeOverlay({
|
||||
switch (currentStep) {
|
||||
case "idle": {
|
||||
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();
|
||||
toast.info("This stream is closed");
|
||||
}
|
||||
@@ -356,8 +381,13 @@ export function ComposeOverlay({
|
||||
if (e.key === "`" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
recordStartRef.current = Date.now();
|
||||
setRecordingSource("media");
|
||||
setStepSync("recording");
|
||||
startRecording();
|
||||
} else if (e.key === "s" || e.key === "S") {
|
||||
e.preventDefault();
|
||||
setRecordingSource("screen");
|
||||
setStepSync("picking");
|
||||
} else if (e.key === "t" || e.key === "T") {
|
||||
e.preventDefault();
|
||||
setStepSync("typing");
|
||||
@@ -370,9 +400,17 @@ export function ComposeOverlay({
|
||||
// Second tap stops recording (toggle mode)
|
||||
e.preventDefault();
|
||||
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") {
|
||||
e.preventDefault();
|
||||
cancelRecording();
|
||||
if (recordingSourceRef.current === "screen") {
|
||||
cancelScreenRecording();
|
||||
} else {
|
||||
cancelRecording();
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
break;
|
||||
@@ -381,7 +419,11 @@ export function ComposeOverlay({
|
||||
case "reviewing": {
|
||||
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelRecording();
|
||||
if (recordingSourceRef.current === "screen") {
|
||||
cancelScreenRecording();
|
||||
} else {
|
||||
cancelRecording();
|
||||
}
|
||||
cancel();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -397,7 +439,7 @@ export function ComposeOverlay({
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (stepRef.current === "recording" && e.key === "`") {
|
||||
if (stepRef.current === "recording" && e.key === "`" && recordingSourceRef.current === "media") {
|
||||
e.preventDefault();
|
||||
// Only stop on release if held long enough (hold-to-record mode).
|
||||
// Quick taps are handled by the second keydown (toggle mode).
|
||||
@@ -413,7 +455,17 @@ export function ComposeOverlay({
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
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 ---
|
||||
|
||||
@@ -425,7 +477,13 @@ export function ComposeOverlay({
|
||||
|
||||
return (
|
||||
<>
|
||||
{(step === "recording" || step === "reviewing") && (
|
||||
{step === "picking" && (
|
||||
<ScreenSourcePicker
|
||||
onSelect={handleScreenSourceSelected}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
{(step === "recording" || step === "reviewing") && recordingSource === "media" && (
|
||||
<RecordingOverlay
|
||||
step={step}
|
||||
mediaStream={mediaStream}
|
||||
@@ -440,6 +498,21 @@ export function ComposeOverlay({
|
||||
dropZoneProps={dropZoneProps}
|
||||
/>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{step === "typing" && (
|
||||
<TextComposeStep
|
||||
textContent={textContent}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface ScreenSourcePickerProps {
|
||||
onSelect: (sourceId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ScreenSourcePicker({ onSelect, onCancel }: ScreenSourcePickerProps) {
|
||||
const [sources, setSources] = useState<ScreenSource[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronScreen.getScreenSources().then((result) => {
|
||||
setSources(result);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-select if there's only one screen and no windows
|
||||
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 (
|
||||
<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="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||
<h2 className="text-base font-medium text-zinc-100">
|
||||
Record your screen
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading ? (
|
||||
<p className="text-center text-sm text-zinc-400">
|
||||
Loading sources...
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{screens.length > 0 && (
|
||||
<SourceSection
|
||||
title="Screens"
|
||||
sources={screens}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
)}
|
||||
{windows.length > 0 && (
|
||||
<SourceSection
|
||||
title="Windows"
|
||||
sources={windows}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={!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"
|
||||
>
|
||||
Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceSection({
|
||||
title,
|
||||
sources,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
sources: ScreenSource[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
key={source.id}
|
||||
onClick={() => onSelect(source.id)}
|
||||
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
||||
selectedId === source.id
|
||||
? "border-blue-500 bg-zinc-800"
|
||||
: "border-transparent bg-zinc-800/50 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={source.thumbnailDataUrl}
|
||||
alt={source.name}
|
||||
className="aspect-video w-full object-cover"
|
||||
/>
|
||||
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">
|
||||
{source.name}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface UseScreenRecorderOptions {
|
||||
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages screen recording via Electron's desktopCapturer.
|
||||
* Captures screen video + mic audio into a single MediaRecorder.
|
||||
* Communicates with the floating control window via IPC.
|
||||
*/
|
||||
export function useScreenRecorder({
|
||||
onFinish,
|
||||
onError,
|
||||
}: UseScreenRecorderOptions) {
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
const micStreamRef = useRef<MediaStream | 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 stopAllTracks = useCallback(() => {
|
||||
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
micStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
micStreamRef.current = null;
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
cleanupIpcRef.current?.();
|
||||
cleanupIpcRef.current = null;
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(
|
||||
async (sourceId: string) => {
|
||||
try {
|
||||
// Screen video via Electron desktopCapturer
|
||||
const screenStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSourceId: sourceId,
|
||||
},
|
||||
} as unknown as MediaTrackConstraints,
|
||||
});
|
||||
screenStreamRef.current = screenStream;
|
||||
|
||||
// Mic audio separately
|
||||
const micStream =
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
micStreamRef.current = micStream;
|
||||
|
||||
// Combine into a single stream
|
||||
const combined = new MediaStream([
|
||||
...screenStream.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();
|
||||
|
||||
// Show the floating control window
|
||||
window.electronScreen.startRecordingWindow();
|
||||
|
||||
// Listen for stop from floating window
|
||||
const cleanup = window.electronScreen.onStopRequested(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
});
|
||||
cleanupIpcRef.current = cleanup;
|
||||
} 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user