first pass implementation

This commit is contained in:
talksik
2026-04-08 16:04:55 -07:00
parent 382f195847
commit 36fbb2acfd
13 changed files with 522 additions and 8 deletions
+4
View File
@@ -76,6 +76,10 @@ const config: ForgeConfig = {
name: 'huddle_window',
config: 'vite.huddle.config.mts',
},
{
name: 'screen_record_window',
config: 'vite.screen-record.config.mts',
},
],
}),
// Fuses are used to enable/disable various Electron functionality
+9
View File
@@ -30,6 +30,15 @@ declare global {
onStop: (callback: () => void) => () => void;
onNavigate: (callback: (data: { networkId: string; streamId: string }) => void) => () => void;
};
electronScreen: {
getScreenSources: () => Promise<ScreenSource[]>;
startRecordingWindow: () => void;
stopRecordingWindow: () => void;
onStopRequested: (callback: () => void) => () => void;
};
electronScreenRecord: {
stop: () => void;
};
electronLink: {
fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
openExternal: (url: string) => Promise<void>;
+81 -8
View File
@@ -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 };
}
@@ -130,6 +130,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
label: "Compose",
bindings: [
{ keys: ["Hold", "`"], description: "Reply" },
{ keys: ["S"], description: "Screen record" },
{ keys: ["T"], description: "Text compose" },
{ keys: ["H"], description: "Join huddle" },
],
@@ -541,6 +542,12 @@ function StreamViewControls({
</kbd>{" "}
to reply
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
S
</kbd>{" "}
screen
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
+52
View File
@@ -25,6 +25,7 @@ if (process.platform === 'darwin' && !app.isPackaged) {
let mainWindow: BrowserWindow | null = null;
let autoplayWindow: BrowserWindow | null = null;
let huddleWindow: BrowserWindow | null = null;
let screenRecordWindow: BrowserWindow | null = null;
const createWindow = () => {
mainWindow = new BrowserWindow({
@@ -121,6 +122,43 @@ function positionAutoplayWindow() {
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
ipcMain.on('window:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize();
@@ -170,6 +208,20 @@ ipcMain.handle('screen:get-sources', async () => {
}));
});
// Screen recording IPC handlers
ipcMain.on('screen-record:start', () => {
createScreenRecordWindow();
screenRecordWindow?.showInactive();
});
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
ipcMain.on('autoplay:play', (_event, payload) => {
if (!autoplayWindow) createAutoplayWindow();
+15
View File
@@ -41,6 +41,21 @@ contextBridge.exposeInMainWorld('electronAutoplay', {
},
});
contextBridge.exposeInMainWorld('electronScreen', {
getScreenSources: () => ipcRenderer.invoke('screen:get-sources'),
startRecordingWindow: () => ipcRenderer.send('screen-record:start'),
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'),
});
contextBridge.exposeInMainWorld('electronLink', {
fetchMetadata: (url: string) => ipcRenderer.invoke('link:fetch-metadata', url),
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { Square } from "lucide-react";
export function ScreenRecordControlApp() {
const [elapsed, setElapsed] = useState(0);
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")}`;
return (
<div className="flex h-screen w-screen items-center justify-center bg-zinc-900">
<div className="flex items-center gap-3 px-4">
<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={() => window.electronScreenRecord.stop()}
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>
);
}
+11
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -6,3 +6,5 @@ declare const AUTOPLAY_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
declare const AUTOPLAY_WINDOW_VITE_NAME: string;
declare const HUDDLE_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
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;
+23
View File
@@ -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)],
},
},
});