Merge pull request #431 from ExtraBinoss/feat/launchwindow-hud-refactor

feat: LaunchWindow refactoring, SourceSelector performance
This commit is contained in:
webadderall
2026-05-08 09:14:42 +10:00
committed by GitHub
39 changed files with 2846 additions and 1960 deletions
+1 -3
View File
@@ -88,9 +88,7 @@ interface Window {
hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setHudOverlayExpanded: (expanded: boolean) => void;
setHudOverlayCompactWidth: (width: number) => void;
setHudOverlayMeasuredHeight: (height: number, expanded: boolean) => void;
hudOverlayRendererReady: () => void;
getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>;
getHudOverlayMousePassthroughSupported: () => Promise<{
success: boolean;
+50 -26
View File
@@ -15,25 +15,19 @@ import {
} from "../cursor/bounds";
const execFileAsync = promisify(execFile);
const SOURCE_LIST_CACHE_TTL_MS = 1200;
let sourceListCache:
| {
key: string;
expiresAt: number;
value: Array<Record<string, unknown>>;
}
| null = null;
function normalizeDesktopSourceName(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
function hasUsableSourceThumbnail(
thumbnail:
| {
isEmpty: () => boolean;
getSize: () => { width: number; height: number };
}
| null
| undefined,
) {
if (!thumbnail || thumbnail.isEmpty()) return false;
const size = thumbnail.getSize();
return size.width > 1 && size.height > 1;
}
function broadcastSelectedSourceChange() {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
@@ -52,8 +46,18 @@ export function registerSourceHandlers({
getSourceSelectorWindow: () => BrowserWindow | null;
}) {
ipcMain.handle("get-sources", async (_, opts) => {
const cacheKey = JSON.stringify({
types: opts?.types,
thumbnailSize: opts?.thumbnailSize,
fetchWindowIcons: opts?.fetchWindowIcons,
});
if (sourceListCache && sourceListCache.key === cacheKey && sourceListCache.expiresAt > Date.now()) {
return sourceListCache.value;
}
const includeScreens = Array.isArray(opts?.types) ? opts.types.includes("screen") : true;
const includeWindows = Array.isArray(opts?.types) ? opts.types.includes("window") : true;
const includeWindowIcons = Boolean(opts?.fetchWindowIcons);
const electronTypes = [
...(includeScreens ? ["screen" as const] : []),
...(includeWindows ? ["window" as const] : []),
@@ -125,7 +129,7 @@ export function registerSourceHandlers({
originalName: matchedSource?.name ?? displayName,
display_id: displayId,
thumbnail: matchedSource?.thumbnail ? matchedSource.thumbnail.toDataURL() : null,
appIcon: matchedSource?.appIcon ? matchedSource.appIcon.toDataURL() : null,
appIcon: null,
sourceType: "screen" as const,
};
});
@@ -133,7 +137,6 @@ export function registerSourceHandlers({
if (process.platform !== "darwin" || !includeWindows) {
const windowSources = electronSources
.filter((source) => source.id.startsWith("window:"))
.filter((source) => hasUsableSourceThumbnail(source.thumbnail))
.filter((source) => {
const normalizedName = normalizeDesktopSourceName(source.name);
if (!normalizedName) {
@@ -159,11 +162,17 @@ export function registerSourceHandlers({
originalName: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
appIcon:
includeWindowIcons && source.appIcon ? source.appIcon.toDataURL() : null,
sourceType: "window" as const,
}));
return [...screenSources, ...windowSources];
const result = [...screenSources, ...windowSources];
sourceListCache = {
key: cacheKey,
expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS,
value: result,
};
return result;
}
try {
@@ -221,17 +230,25 @@ export function registerSourceHandlers({
? electronWindowSource.thumbnail.toDataURL()
: null,
appIcon:
source.appIcon ??
(electronWindowSource?.appIcon
? electronWindowSource.appIcon.toDataURL()
: null),
includeWindowIcons
? (source.appIcon ??
(electronWindowSource?.appIcon
? electronWindowSource.appIcon.toDataURL()
: null))
: null,
appName: source.appName,
windowTitle: source.windowTitle,
sourceType: "window" as const,
};
});
return [...screenSources, ...mergedWindowSources];
const result = [...screenSources, ...mergedWindowSources];
sourceListCache = {
key: cacheKey,
expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS,
value: result,
};
return result;
} catch (error) {
console.warn("Falling back to Electron window enumeration on macOS:", error);
@@ -266,11 +283,18 @@ export function registerSourceHandlers({
originalName: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
appIcon:
includeWindowIcons && source.appIcon ? source.appIcon.toDataURL() : null,
sourceType: "window" as const,
}));
return [...screenSources, ...windowSources];
const result = [...screenSources, ...windowSources];
sourceListCache = {
key: cacheKey,
expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS,
value: result,
};
return result;
}
});
+2 -8
View File
@@ -84,14 +84,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close");
},
setHudOverlayExpanded: (expanded: boolean) => {
ipcRenderer.send("set-hud-overlay-expanded", expanded);
},
setHudOverlayCompactWidth: (width: number) => {
ipcRenderer.send("set-hud-overlay-compact-width", width);
},
setHudOverlayMeasuredHeight: (height: number, expanded: boolean) => {
ipcRenderer.send("set-hud-overlay-measured-height", height, expanded);
hudOverlayRendererReady: () => {
ipcRenderer.send("hud-overlay-renderer-ready");
},
getHudOverlayCaptureProtection: () => {
return ipcRenderer.invoke("get-hud-overlay-capture-protection");
+46 -147
View File
@@ -26,23 +26,11 @@ let countdownWindow: BrowserWindow | null = null;
let updateToastWindow: BrowserWindow | null = null;
const HUD_OVERLAY_SETTINGS_FILE = path.join(USER_DATA_PATH, "hud-overlay-settings.json");
const HUD_BOTTOM_CLEARANCE_CM = 3.5;
const DIP_PER_INCH = 96;
const CM_PER_INCH = 2.54;
const HUD_EDGE_MARGIN_DIP = 16;
const HUD_SHADOW_BLEED_DIP = 36;
const HUD_MIN_WINDOW_WIDTH = 560;
const HUD_COMPACT_HEIGHT = 96;
const HUD_MIN_EXPANDED_HEIGHT = 520 + HUD_SHADOW_BLEED_DIP;
const UPDATE_TOAST_WIDTH = 456;
const UPDATE_TOAST_HEIGHT = 252;
const UPDATE_TOAST_GAP_DIP = 18;
let hudOverlayExpanded = false;
let hudOverlayCompactWidth = HUD_MIN_WINDOW_WIDTH;
let hudOverlayCompactHeight = HUD_COMPACT_HEIGHT;
let hudOverlayExpandedHeight = HUD_MIN_EXPANDED_HEIGHT;
function getEditorWindowQuery(): Record<string, string> {
const query: Record<string, string> = {
windowType: "editor",
@@ -182,61 +170,21 @@ function getHudOverlayDisplay() {
return getScreen().getPrimaryDisplay();
}
function getHudOverlayBounds(expanded: boolean) {
const { bounds, workArea } = getHudOverlayDisplay();
const maxWindowWidth = Math.max(HUD_MIN_WINDOW_WIDTH, workArea.width - HUD_EDGE_MARGIN_DIP * 2);
const windowWidth = Math.min(
maxWindowWidth,
Math.max(HUD_MIN_WINDOW_WIDTH, Math.round(hudOverlayCompactWidth)),
);
const maxWindowHeight = Math.max(HUD_COMPACT_HEIGHT, workArea.height - HUD_EDGE_MARGIN_DIP * 2);
const desiredHeight = expanded
? Math.max(HUD_MIN_EXPANDED_HEIGHT, Math.round(hudOverlayExpandedHeight))
: Math.max(HUD_COMPACT_HEIGHT, Math.round(hudOverlayCompactHeight));
const windowHeight = Math.min(maxWindowHeight, desiredHeight);
const bottomClearanceDip = Math.round((HUD_BOTTOM_CLEARANCE_CM / CM_PER_INCH) * DIP_PER_INCH);
const screenBottom = bounds.y + bounds.height;
const workAreaBottom = workArea.y + workArea.height;
const preferredBottom = screenBottom - bottomClearanceDip;
const maximumSafeBottom = workAreaBottom - HUD_EDGE_MARGIN_DIP;
const windowBottom = Math.min(preferredBottom, maximumSafeBottom);
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
const y = Math.max(workArea.y + HUD_EDGE_MARGIN_DIP, Math.floor(windowBottom - windowHeight));
function getHudOverlayBounds() {
const { workArea } = getHudOverlayDisplay();
return {
x,
y,
width: windowWidth,
height: windowHeight,
x: workArea.x,
y: workArea.y,
width: workArea.width,
height: workArea.height,
};
}
function applyHudOverlayBounds(expanded: boolean) {
function applyHudOverlayBounds() {
if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) {
return;
}
hudOverlayExpanded = expanded;
const computed = getHudOverlayBounds(expanded);
if (hudUserPosition) {
// Resize in-place at the user's dragged position, clamped so the
// window stays fully within the current display's work area.
const { workArea } = getHudOverlayDisplay();
const x = Math.max(
workArea.x,
Math.min(hudUserPosition.x, workArea.x + workArea.width - computed.width),
);
const y = Math.max(
workArea.y,
Math.min(hudUserPosition.y, workArea.y + workArea.height - computed.height),
);
hudOverlayWindow.setBounds({ x, y, width: computed.width, height: computed.height }, false);
} else {
hudOverlayWindow.setBounds(computed, false);
}
hudOverlayWindow.setBounds(getHudOverlayBounds(), false);
positionUpdateToastWindow();
if (!hudOverlayWindow.isVisible()) {
@@ -299,9 +247,7 @@ ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => {
}
});
// When the user drags the HUD, remember their chosen position so that
// subsequent size changes (e.g. idle → recording UI swap) resize in-place
// instead of snapping back to the default centered location.
// Keep compatibility with existing drag IPC/state.
let hudUserPosition: { x: number; y: number } | null = null;
let hudDragOffset: { x: number; y: number } | null = null;
let hudDragLastCursor: { x: number; y: number } | null = null;
@@ -315,7 +261,7 @@ ipcMain.on("hud-overlay-drag", (_event, phase: string, screenX: number, screenY:
// the HUD appears "stuck". The renderer marks the drag handle as
// -webkit-app-region: drag on Linux, letting the OS move the window for us.
// The resulting position is captured by the win.on("moved", ...) listener
// below so `hudUserPosition` stays in sync for in-place resize.
// below so `hudUserPosition` stays in sync.
if (process.platform === "linux") {
return;
}
@@ -359,58 +305,13 @@ ipcMain.on("hud-overlay-drag", (_event, phase: string, screenX: number, screenY:
});
ipcMain.on("hud-overlay-hide", () => {
// Keep the HUD persistent: accidental hide events should not minimize it.
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
}
});
ipcMain.on("set-hud-overlay-expanded", (_event, expanded: boolean) => {
applyHudOverlayBounds(Boolean(expanded));
});
ipcMain.on("set-hud-overlay-compact-width", (_event, width: number) => {
if (!Number.isFinite(width)) {
return;
}
const maxWindowWidth = Math.max(
HUD_MIN_WINDOW_WIDTH,
getHudOverlayDisplay().workArea.width - HUD_EDGE_MARGIN_DIP * 2,
);
const nextWidth = Math.min(maxWindowWidth, Math.max(HUD_MIN_WINDOW_WIDTH, Math.round(width)));
if (nextWidth === hudOverlayCompactWidth) {
return;
}
hudOverlayCompactWidth = nextWidth;
applyHudOverlayBounds(hudOverlayExpanded);
});
ipcMain.on("set-hud-overlay-measured-height", (_event, height: number, expanded: boolean) => {
if (!Number.isFinite(height)) {
return;
}
const maxWindowHeight = Math.max(
HUD_COMPACT_HEIGHT,
getHudOverlayDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2,
);
const nextHeight = Math.min(maxWindowHeight, Math.max(HUD_COMPACT_HEIGHT, Math.round(height)));
if (expanded) {
if (nextHeight === hudOverlayExpandedHeight) {
return;
if (!hudOverlayWindow.isVisible()) {
hudOverlayWindow.show();
}
hudOverlayExpandedHeight = Math.max(HUD_MIN_EXPANDED_HEIGHT, nextHeight);
} else {
if (nextHeight === hudOverlayCompactHeight) {
return;
}
hudOverlayCompactHeight = nextHeight;
hudOverlayWindow.moveTop();
}
applyHudOverlayBounds(hudOverlayExpanded);
});
ipcMain.handle("get-hud-overlay-capture-protection", () => {
@@ -450,21 +351,17 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean)
export function createHudOverlayWindow(): BrowserWindow {
loadHudOverlayCaptureProtectionSetting();
const initialBounds = getHudOverlayBounds(false);
const initialBounds = getHudOverlayBounds();
let hasShownHudWindow = false;
const win = new BrowserWindow({
width: initialBounds.width,
height: initialBounds.height,
minWidth: HUD_MIN_WINDOW_WIDTH,
minHeight: HUD_COMPACT_HEIGHT,
maxHeight: Math.max(
HUD_COMPACT_HEIGHT,
getHudOverlayDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2,
),
x: initialBounds.x,
y: initialBounds.y,
frame: false,
transparent: true,
backgroundColor: "#00000000",
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
@@ -479,6 +376,23 @@ export function createHudOverlayWindow(): BrowserWindow {
},
});
const showHudWindow = () => {
if (hasShownHudWindow || win.isDestroyed()) {
return;
}
hasShownHudWindow = true;
win.show();
win.moveTop();
if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) {
win.setIgnoreMouseEvents(false);
setTimeout(() => {
if (!win.isDestroyed()) {
win.setIgnoreMouseEvents(true, { forward: true });
}
}, 50);
}
};
if (isHudOverlayCaptureProtectionSupported()) {
win.setContentProtection(hudOverlayHiddenFromCapture);
}
@@ -508,39 +422,23 @@ export function createHudOverlayWindow(): BrowserWindow {
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
// Safety fallback if renderer-ready signal never arrives.
setTimeout(() => {
if (!win.isDestroyed()) {
win.show();
win.moveTop();
if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) {
win.setIgnoreMouseEvents(false);
setTimeout(() => {
if (!win.isDestroyed()) {
win.setIgnoreMouseEvents(true, { forward: true });
}
}, 50);
}
}
}, 100);
showHudWindow();
}, 1800);
});
// Safety net: on Linux the renderer may fail to fire did-finish-load
// (e.g. GPU/VAAPI errors). Show the window after ready-to-show as fallback.
win.once("ready-to-show", () => {
setTimeout(() => {
if (!win.isDestroyed() && !win.isVisible()) {
win.show();
win.moveTop();
}
}, 500);
});
const handleHudRendererReady = () => {
if (!win.isDestroyed()) {
showHudWindow();
}
};
ipcMain.on("hud-overlay-renderer-ready", handleHudRendererReady);
hudOverlayWindow = win;
// On Linux the HUD is dragged by the OS via -webkit-app-region (Wayland
// forbids client-side positioning). Mirror the resulting bounds into
// hudUserPosition so subsequent expand/collapse resizes stay in place
// instead of snapping back to the default centered spot.
// forbids client-side positioning). Mirror moved bounds into drag state.
if (process.platform === "linux") {
win.on("moved", () => {
if (win.isDestroyed()) return;
@@ -569,12 +467,13 @@ export function createHudOverlayWindow(): BrowserWindow {
hudUserPosition = null;
}
}
applyHudOverlayBounds(hudOverlayExpanded);
applyHudOverlayBounds();
};
screen.on("display-removed", handleDisplayRemoved);
screen.on("display-metrics-changed", handleDisplayMetricsChanged);
win.on("closed", () => {
ipcMain.removeListener("hud-overlay-renderer-ready", handleHudRendererReady);
screen.removeListener("display-removed", handleDisplayRemoved);
screen.removeListener("display-metrics-changed", handleDisplayMetricsChanged);
if (hudOverlayWindow === win) {
+7 -1
View File
@@ -19,6 +19,7 @@ export default function App() {
const type = params.get("windowType") || "";
const isMacOS = /mac/i.test(navigator.platform);
setWindowType(type);
document.documentElement.dataset.windowType = type;
if (
type === "hud-overlay" ||
@@ -31,7 +32,12 @@ export default function App() {
document.getElementById("root")?.style.setProperty("background", "transparent");
}
if (type === "hud-overlay" || type === "update-toast") {
if (type === "hud-overlay") {
document.documentElement.classList.add("hud-overlay-window");
document.body.classList.add("hud-overlay-window");
document.getElementById("root")?.classList.add("hud-overlay-window");
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
} else if (type === "update-toast") {
document.documentElement.style.overflow = "visible";
document.body.style.overflow = "visible";
document.getElementById("root")?.style.setProperty("overflow", "visible");
+26 -70
View File
@@ -12,14 +12,11 @@
gap: 10px;
width: max-content;
max-width: 1200px;
background: rgba(18, 18, 24, 0.97);
border: 1px solid rgba(255, 255, 255, 0.07);
background: var(--launch-bar-bg);
border: 1px solid var(--launch-bar-border);
border-radius: 20px;
padding: 11px 20px 11px 18px;
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.24),
0 2px 10px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: var(--launch-bar-shadow);
overflow: visible;
position: relative;
transform-origin: center bottom;
@@ -48,7 +45,7 @@
.sep {
width: 1px;
height: 26px;
background: #2a2a34;
background: var(--launch-border-strong);
margin: 0 6px;
flex-shrink: 0;
}
@@ -60,7 +57,7 @@
border-radius: 11px;
border: none;
background: transparent;
color: #6b6b78;
color: var(--launch-text-muted);
cursor: pointer;
display: inline-flex;
align-items: center;
@@ -70,8 +67,8 @@
}
.ib:hover {
background: rgba(255, 255, 255, 0.07);
color: #eeeef2;
background: var(--launch-hover);
color: var(--launch-text);
}
.ibActive {
@@ -98,63 +95,23 @@
color: #4eeeb0;
}
.screenSel {
position: relative;
display: inline-flex;
align-items: center;
gap: 7px;
height: 40px;
min-width: 0;
max-width: 640px;
padding: 0 14px 0 12px;
border-radius: 11px;
border: 1px solid #2a2a34;
background: #1a1a22;
color: #eeeef2;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
flex: 0 1 auto;
}
.screenSel:hover {
border-color: #3e3e4c;
background: #20202a;
}
.sourceLabel {
display: inline-block;
min-width: 0;
max-width: 520px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menuArea {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
overflow: visible;
min-height: 0;
}
.menuCard {
width: 300px;
max-height: 400px;
overflow-y: auto;
background: rgba(22, 22, 30, 0.96);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 14px;
background: var(--launch-surface);
border: 1px solid var(--launch-border);
border-radius: 16px;
backdrop-filter: blur(var(--launch-popover-blur, 24px))
saturate(var(--launch-popover-saturate, 145%));
-webkit-backdrop-filter: blur(var(--launch-popover-blur, 24px))
saturate(var(--launch-popover-saturate, 145%));
padding: 8px;
margin-top: auto;
margin-bottom: 8px;
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.22),
0 2px 10px rgba(0, 0, 0, 0.1);
box-shadow: var(--launch-bar-shadow);
pointer-events: auto;
animation: menuCardIn 0.18s ease;
}
@@ -179,14 +136,14 @@
}
.menuCard::-webkit-scrollbar-thumb {
background: #2a2a34;
border-radius: 2px;
background: var(--launch-border-strong);
border-radius: 10px;
}
.ddLabel {
font-size: 9px;
font-weight: 600;
color: #6b6b78;
color: var(--launch-label);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 6px 10px 4px;
@@ -202,19 +159,20 @@
border: none;
background: transparent;
font-size: 12px;
color: #6b6b78;
color: var(--launch-text-muted);
cursor: pointer;
transition: all 0.12s ease;
text-align: left;
}
.ddItem:hover {
background: rgba(255, 255, 255, 0.06);
color: #eeeef2;
background: var(--launch-hover);
color: var(--launch-text);
}
.ddItemSelected {
color: #3d8bff;
background: var(--launch-selected);
color: var(--launch-accent);
}
.finalizingState {
@@ -306,11 +264,9 @@
width: 288px;
overflow: hidden;
border-radius: 24px;
background: rgba(22, 22, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 10px 24px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
background: var(--launch-surface);
border: 1px solid var(--launch-border-strong);
box-shadow: var(--launch-bar-shadow);
cursor: grab;
touch-action: none;
user-select: none;
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
import { MicrophoneIcon, MicrophoneSlashIcon, MinusIcon, PauseIcon, PlayIcon, SquareIcon, XIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import styles from "./LaunchWindow.module.css";
interface RecordingControlsProps {
paused: boolean;
microphoneEnabled: boolean;
elapsed: number;
onToggleMicrophone: () => void;
onPauseResume: () => void;
onStopRecording: () => void;
onHideHud: () => void;
onCancelRecording: () => void;
formatTime: (seconds: number) => string;
}
export const RecordingControls = ({
paused,
microphoneEnabled,
elapsed,
onToggleMicrophone,
onPauseResume,
onStopRecording,
onHideHud,
onCancelRecording,
formatTime,
}: RecordingControlsProps) => {
const t = useScopedT("launch");
const memoizedControls = useMemo(() => {
return (
<>
<div className="flex items-center gap-[5px]">
<div
className={`w-[7px] h-[7px] rounded-full ${
paused ? "bg-[#fbbf24]" : `bg-[#f43f5e] ${styles.recDotBlink}`
}`}
/>
<span
className={`text-[10px] font-bold tracking-[0.06em] ${
paused ? "text-[#fbbf24]" : "text-[#f43f5e]"
}`}
>
{paused ? t("recording.paused") : t("recording.rec")}
</span>
</div>
<span
className={`font-mono text-xs font-semibold min-w-[52px] text-center tracking-[0.02em] ${
paused ? "text-[#fbbf24]" : "text-[var(--launch-text)]"
}`}
>
{formatTime(elapsed)}
</span>
<Separator orientation="vertical" className="mx-[5px] h-6" />
<span title={t("recording.micToggleDisabledTip")}>
<Button
variant="ghost"
size="icon"
iconSize="lg"
className={microphoneEnabled ? styles.ibActive : ""}
aria-label={t("recording.micToggleDisabledTip")}
disabled
onClick={onToggleMicrophone}
>
{microphoneEnabled ? (
<MicrophoneIcon size={18} />
) : (
<MicrophoneSlashIcon size={18} />
)}
</Button>
</span>
<Separator orientation="vertical" className="mx-[5px] h-6" />
<Button
variant={paused ? "default" : "ghost"}
size="icon"
iconSize="lg"
onClick={onPauseResume}
title={paused ? t("recording.resume") : t("recording.pause")}
aria-label={paused ? t("recording.resume") : t("recording.pause")}
className={paused ? styles.ibGreen : ""}
>
{paused ? (
<PlayIcon size={18} fill="currentColor" strokeWidth={0} />
) : (
<PauseIcon size={18} />
)}
</Button>
<Button
variant="ghost"
size="icon"
iconSize="lg"
onClick={onStopRecording}
title={t("recording.stop")}
aria-label={t("recording.stop")}
className={styles.ibRed}
>
<SquareIcon size={16} fill="currentColor" strokeWidth={0} />
</Button>
<Button
variant="ghost"
size="icon"
iconSize="lg"
onClick={onHideHud}
title={t("recording.hideHud")}
aria-label={t("recording.hideHud")}
>
<MinusIcon size={16} />
</Button>
<Button
variant="ghost"
size="icon"
iconSize="lg"
onClick={onCancelRecording}
title={t("recording.cancel")}
aria-label={t("recording.cancel")}
>
<XIcon size={18} />
</Button>
</>
);
}, [
paused,
microphoneEnabled,
elapsed,
onToggleMicrophone,
onPauseResume,
onStopRecording,
onHideHud,
onCancelRecording,
formatTime,
t,
]);
return memoizedControls;
};
+142
View File
@@ -0,0 +1,142 @@
.source-selector-popover {
background: var(--launch-surface);
border: 1px solid var(--launch-border);
border-radius: 16px;
backdrop-filter: blur(var(--launch-popover-blur, 24px))
saturate(var(--launch-popover-saturate, 145%));
-webkit-backdrop-filter: blur(var(--launch-popover-blur, 24px))
saturate(var(--launch-popover-saturate, 145%));
box-shadow: var(--launch-bar-shadow);
}
.source-selector-item {
color: var(--launch-text-muted);
}
.source-selector-item:hover {
background: var(--launch-hover);
color: var(--launch-text);
}
.source-selector-item-selected {
background: var(--launch-selected);
color: var(--launch-accent);
}
.source-selector-thumb-fallback {
background: var(--launch-panel);
}
.source-selector-text {
color: var(--launch-text);
}
.source-selector-muted {
color: var(--launch-text-muted);
}
.source-selector-subtle {
color: var(--launch-text-muted);
opacity: 0.7;
}
.source-selector-label {
color: var(--launch-label);
}
.source-selector-dot {
background: var(--launch-accent);
}
.source-selector-dot-inner {
background: #f8fbff;
}
.source-selector-accent-border {
border-color: var(--launch-accent);
}
/* Custom Scrollbar for Source Selector */
.source-selector-scroll::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.source-selector-scroll::-webkit-scrollbar-track {
background: transparent;
}
.source-selector-scroll::-webkit-scrollbar-thumb {
background: rgba(107, 107, 120, 0.2);
border-radius: 20px;
transition: background 0.2s ease;
}
.source-selector-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(107, 107, 120, 0.4);
}
/* Support for Firefox */
.source-selector-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(107, 107, 120, 0.2) transparent;
}
.source-selector-marquee {
position: relative;
overflow: hidden;
white-space: nowrap;
}
.source-selector-marquee-static {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-selector-marquee-animated {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.source-selector-marquee-track {
display: inline-flex;
max-width: none;
white-space: nowrap;
will-change: transform;
animation: source-selector-marquee 10s linear infinite;
animation-play-state: paused;
}
.source-selector-marquee-segment {
display: inline-block;
}
.source-selector-marquee-duplicate {
padding-left: 20px;
}
.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-static {
opacity: 0;
}
.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-animated {
opacity: 1;
}
.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-track {
animation-play-state: running;
}
@keyframes source-selector-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
+353 -301
View File
@@ -1,322 +1,374 @@
import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useState } from "react";
import { MdCheck } from "react-icons/md";
import { useScopedT } from "../../contexts/I18nContext";
import { Button } from "../ui/button";
import { Card } from "../ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import styles from "./SourceSelector.module.css";
import * as React from "react";
import { MonitorIcon, AppWindowIcon, CaretUpIcon } from "@phosphor-icons/react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
mapRawSource,
isScreenSource,
isWindowSource,
type DesktopSource,
} from "./popovers/launchPopoverTypes";
import "./launchTheme.css";
import "./SourceSelector.css";
import { useHudInteraction } from "./contexts/HudInteractionContext";
interface DesktopSource {
id: string;
name: string;
thumbnail: string | null;
display_id: string;
appIcon: string | null;
originalName: string;
sourceType: "screen" | "window";
appName?: string;
windowTitle?: string;
interface SourceSelectorProps {
/** List of available screen sources */
screenSources?: DesktopSource[];
/** List of available window sources */
windowSources?: DesktopSource[];
/** Currently selected source name */
selectedSource?: string;
/** Loading state */
loading?: boolean;
/** Callback when a source is selected */
onSourceSelect?: (source: DesktopSource) => void;
/** Callback to fetch sources */
onFetchSources?: () => Promise<void>;
/** Whether the popover is open */
open?: boolean;
/** Callback when open state changes */
onOpenChange?: (open: boolean) => void;
/** Optional custom trigger element */
children?: React.ReactNode;
}
function parseSourceMetadata(source: ProcessedDesktopSource) {
if (source.sourceType === "window" && (source.appName || source.windowTitle)) {
return {
sourceType: "window" as const,
appName: source.appName,
windowTitle: source.windowTitle ?? source.name,
displayName: source.windowTitle ?? source.name,
export function MarqueeText({ text }: { text: string }) {
const staticRef = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const node = staticRef.current;
if (!node) return;
const checkOverflow = () => {
setOverflowing(node.scrollWidth > node.clientWidth + 1);
};
}
checkOverflow();
const observer = new ResizeObserver(checkOverflow);
observer.observe(node);
return () => observer.disconnect();
}, [text]);
const sourceType: "screen" | "window" = source.id.startsWith("window:") ? "window" : "screen";
if (sourceType === "window") {
const [appNamePart, ...windowTitleParts] = source.name.split(" — ");
const appName = appNamePart?.trim() || undefined;
const windowTitle = windowTitleParts.join(" — ").trim() || source.name.trim();
return {
sourceType,
appName,
windowTitle,
displayName: windowTitle,
};
}
return {
sourceType,
appName: undefined,
windowTitle: undefined,
displayName: source.name,
};
return (
<div
className="w-full source-selector-marquee"
data-overflowing={overflowing ? "true" : "false"}
>
<span ref={staticRef} className="source-selector-marquee-static">
{text}
</span>
<span className="source-selector-marquee-animated">
<span className="source-selector-marquee-track">
<span className="source-selector-marquee-segment">{text}</span>
<span className="source-selector-marquee-segment source-selector-marquee-duplicate">
{text}
</span>
</span>
</span>
</div>
);
}
export function SourceSelector() {
/**
* SourceSelectorContent - The actual list of sources
*/
export const SourceSelectorContent = ({
screenSources = [],
windowSources = [],
selectedSource = "Screen",
loading = false,
onSourceSelect = () => {},
}: Pick<SourceSelectorProps, "screenSources" | "windowSources" | "selectedSource" | "loading" | "onSourceSelect">) => {
const t = useScopedT("launch");
const [sources, setSources] = useState<DesktopSource[]>([]);
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
const [activeTab, setActiveTab] = useState<"screens" | "windows">("screens");
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchSources() {
setLoading(true);
try {
const rawSources = await window.electronAPI.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true,
});
setSources(
rawSources.map((source) => {
const metadata = parseSourceMetadata(source);
return {
id: source.id,
name: metadata.displayName,
thumbnail: source.thumbnail,
display_id: source.display_id,
appIcon: source.appIcon,
originalName: source.name,
sourceType: metadata.sourceType,
appName: metadata.appName,
windowTitle: metadata.windowTitle,
};
}),
);
} catch (error) {
console.error("Error loading sources:", error);
} finally {
setLoading(false);
}
}
fetchSources();
}, []);
const screenSources = sources.filter((s) => s.id.startsWith("screen:"));
const windowSources = sources.filter((s) => s.id.startsWith("window:"));
useEffect(() => {
if (loading) {
return;
}
if (screenSources.length === 0 && windowSources.length > 0) {
setActiveTab("windows");
return;
}
if (windowSources.length === 0 && screenSources.length > 0) {
setActiveTab("screens");
}
}, [loading, screenSources.length, windowSources.length]);
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
const handleSourceKeyDown = (
event: ReactKeyboardEvent<HTMLDivElement>,
source: DesktopSource,
) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
handleSourceSelect(source);
};
const handleShare = async () => {
if (selectedSource) await window.electronAPI.selectSource(selectedSource);
};
if (loading) {
const renderSourceItem = (source: DesktopSource, index: number) => {
const isSelected = selectedSource === source.name;
return (
<div
className={`h-full flex items-center justify-center ${styles.glassContainer}`}
style={{ minHeight: "100vh" }}
<button
key={`${source.id}-${index}`}
type="button"
className={cn(
"source-selector-item group min-h-[46px] w-full rounded-[11px] px-3 py-2.5 text-left font-medium flex items-center justify-start gap-3",
isSelected && "source-selector-item-selected",
)}
onClick={() => onSourceSelect(source)}
>
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-zinc-600 mx-auto mb-2" />
<p className="text-xs text-zinc-300">{t("sourceSelector.loadingSources")}</p>
<div className="relative flex-shrink-0">
{source.thumbnail ? (
<img
src={source.thumbnail}
alt=""
className="w-12 h-8 rounded-[8px] object-cover bg-black/50"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="source-selector-thumb-fallback w-12 h-8 rounded-[8px] flex items-center justify-center">
{source.sourceType === "window" ? (
<AppWindowIcon className="w-5 h-5 source-selector-muted" />
) : (
<MonitorIcon className="w-5 h-5 source-selector-muted" />
)}
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col items-start text-left">
<div className="text-sm font-medium source-selector-text w-full">
<MarqueeText text={source.windowTitle || source.name} />
</div>
<div className="text-xs source-selector-subtle truncate w-full text-left">
{source.sourceType === "screen" ? t("recording.screen") : t("recording.window")}
</div>
</div>
</button>
);
};
const hasAnySources = screenSources.length > 0 || windowSources.length > 0;
if (loading && !hasAnySources) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 source-selector-accent-border" />
</div>
);
}
return (
<div
className={`min-h-screen flex flex-col items-center justify-center ${styles.glassContainer}`}
>
<div className="flex-1 flex flex-col w-full max-w-xl" style={{ padding: 0 }}>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "screens" | "windows")}
>
<TabsList className="grid grid-cols-2 mb-3 bg-zinc-900/40 rounded-full">
<TabsTrigger
value="screens"
className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1"
>
{t("sourceSelector.screens")} ({screenSources.length})
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1"
>
{t("sourceSelector.windows")} ({windowSources.length})
</TabsTrigger>
</TabsList>
<div className="h-72 flex flex-col justify-stretch">
<TabsContent value="screens" className="h-full">
<div
className={`grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative ${styles.sourceGridScroll}`}
>
{screenSources.length === 0 && (
<div className="col-span-2 text-center text-xs text-zinc-500 py-8">
{t("sourceSelector.noScreensAvailable")}
</div>
)}
{screenSources.map((source) => {
const isSelected = selectedSource?.id === source.id;
return (
<Card
key={source.id}
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} cursor-pointer h-fit p-2 scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent`}
style={{ margin: 8, width: "90%", maxWidth: 220 }}
onClick={() => handleSourceSelect(source)}
onKeyDown={(event) =>
handleSourceKeyDown(event, source)
}
role="button"
tabIndex={0}
aria-pressed={isSelected}
>
<div className="p-1">
<div className="relative mb-1">
<img
src={source.thumbnail || ""}
alt={source.name}
className="w-full aspect-video object-cover rounded border border-zinc-800"
/>
{isSelected && (
<div className="absolute -top-1 -right-1">
<div className="w-4 h-4 bg-[#2563EB] rounded-full flex items-center justify-center shadow-md">
<MdCheck className={styles.icon} />
</div>
</div>
)}
</div>
<div className={styles.name + " truncate"}>
{source.name}
</div>
</div>
</Card>
);
})}
<div className="max-h-[320px] overflow-y-auto overflow-x-hidden p-2 source-selector-scroll">
{hasAnySources ? (
<>
{screenSources.length > 0 ? (
<div className="space-y-1">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] source-selector-label flex items-center gap-2">
{t("recording.screens")}
<span
className={cn(
"normal-case tracking-normal text-[10px] source-selector-muted transition-opacity duration-150",
loading ? "opacity-100" : "opacity-0",
)}
>
{t("common.loading", "Refreshing...")}
</span>
</div>
</TabsContent>
<TabsContent value="windows" className="h-full">
<p className="text-[10px] text-zinc-500 mb-1 px-1">
{t("sourceSelector.windowsNote")}
</p>
<div
className={`grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative ${styles.sourceGridScroll}`}
>
{windowSources.length === 0 && (
<div className="col-span-2 text-center text-xs text-zinc-500 py-8">
{t("sourceSelector.noWindowsAvailable")}
</div>
)}
{windowSources.map((source) => {
const isSelected = selectedSource?.id === source.id;
return (
<Card
key={source.id}
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} cursor-pointer h-fit p-2 scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent`}
style={{ margin: 8, width: "90%", maxWidth: 220 }}
onClick={() => handleSourceSelect(source)}
onKeyDown={(event) =>
handleSourceKeyDown(event, source)
}
role="button"
tabIndex={0}
aria-pressed={isSelected}
>
<div className="p-1">
<div className="relative mb-1">
{source.thumbnail ? (
<img
src={source.thumbnail}
alt={source.name}
className="w-full aspect-video object-cover rounded border border-gray-700"
/>
) : (
<div className="w-full aspect-video rounded border border-gray-700 bg-zinc-900/80 flex flex-col items-center justify-center text-zinc-400 gap-2">
{source.appIcon ? (
<img
src={source.appIcon}
alt="App icon"
className="w-8 h-8 rounded-md"
/>
) : (
<div className="w-8 h-8 rounded-md bg-zinc-800 border border-zinc-700" />
)}
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">
{t(
"sourceSelector.windowPlaceholder",
)}
</div>
</div>
)}
{isSelected && (
<div className="absolute -top-1 -right-1">
<div className="w-4 h-4 bg-blue-600 rounded-full flex items-center justify-center shadow-md">
<MdCheck className={styles.icon} />
</div>
</div>
)}
</div>
<div className="flex items-center gap-1">
{source.appIcon && (
<img
src={source.appIcon}
alt="App icon"
className={
styles.icon + " flex-shrink-0"
}
/>
)}
<div className={styles.name + " truncate"}>
{source.name}
</div>
</div>
</div>
</Card>
);
})}
<div className="space-y-0.5">
{screenSources.map((source, index) => renderSourceItem(source, index))}
</div>
</TabsContent>
</div>
</Tabs>
</div>
<div className="border-t border-zinc-800 p-2 w-full max-w-xl">
<div className="flex justify-center gap-2">
<Button
variant="outline"
onClick={() => window.close()}
className="px-4 py-1 text-xs bg-zinc-800 border-zinc-700 text-zinc-200 hover:bg-zinc-700"
>
{t("sourceSelector.cancel")}
</Button>
<Button
onClick={handleShare}
disabled={!selectedSource}
className="px-4 py-1 text-xs bg-[#2563EB] text-white hover:bg-[#2563EB]/80 disabled:opacity-50 disabled:bg-zinc-700"
>
{t("sourceSelector.share")}
</Button>
</div>
) : null}
{windowSources.length > 0 ? (
<div className="space-y-1">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] source-selector-label">
{t("recording.windows")}
</div>
<div className="space-y-0.5">
{windowSources.map((source, index) => renderSourceItem(source, index))}
</div>
</div>
) : null}
</>
) : (
<div className="text-center py-8 text-sm source-selector-muted">
{t("recording.noSourcesFound")}
</div>
</div>
)}
</div>
);
}
};
/**
* SourceSelector - A rich source selection component with thumbnails
* Uses Radix UI Popover for positioning and accessibility
*/
export const SourceSelector = React.memo(function SourceSelector({
screenSources: propsScreenSources,
windowSources: propsWindowSources,
selectedSource: propsSelectedSource,
loading: propsLoading,
onSourceSelect: propsOnSourceSelect,
onFetchSources: propsOnFetchSources,
open: propsOpen,
onOpenChange: propsOnOpenChange,
children,
}: SourceSelectorProps) {
// Internal state for standalone/uncontrolled use
const [internalOpen, setInternalOpen] = useState(false);
const [internalSources, setInternalSources] = useState<DesktopSource[]>([]);
const [internalLoading, setInternalLoading] = useState(false);
const [internalSelectedSource, setInternalSelectedSource] = useState("Screen");
// Determine if we should use internal or external state/logic
const isAutonomous = propsOpen === undefined;
const open = propsOpen ?? internalOpen;
const onOpenChange = propsOnOpenChange ?? setInternalOpen;
const loading = propsLoading ?? internalLoading;
const selectedSource = propsSelectedSource ?? internalSelectedSource;
// Default fetching logic
const defaultFetchSources = useCallback(async () => {
if (!window.electronAPI) return;
setInternalLoading(true);
try {
const rawSources = await window.electronAPI.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 160, height: 90 },
fetchWindowIcons: true,
});
setInternalSources(rawSources.map((s) => mapRawSource(s as DesktopSource)));
} catch (error) {
console.error("Failed to fetch sources:", error);
} finally {
setInternalLoading(false);
}
}, []);
const onFetchSources = propsOnFetchSources ?? defaultFetchSources;
// Default selection logic
const onSourceSelect = useCallback(
async (source: DesktopSource) => {
if (propsOnSourceSelect) {
propsOnSourceSelect(source);
return;
}
if (!window.electronAPI) return;
try {
const result = await window.electronAPI.selectSource(source);
if (result) {
setInternalSelectedSource(source.name);
}
} catch (error) {
console.error("Failed to select source:", error);
}
},
[propsOnSourceSelect],
);
// Split sources for internal use
const internalScreenSources = useMemo(
() => internalSources.filter(isScreenSource),
[internalSources],
);
const internalWindowSources = useMemo(
() => internalSources.filter(isWindowSource),
[internalSources],
);
const screenSources = propsScreenSources ?? internalScreenSources;
const windowSources = propsWindowSources ?? internalWindowSources;
const hasPrefetchedRef = useRef(false);
const fetchInFlightRef = useRef(false);
const lastFetchedAtRef = useRef(0);
const fetchSourcesOnce = useCallback(
async (allowRecentSkip: boolean) => {
if (fetchInFlightRef.current) {
return;
}
if (allowRecentSkip && Date.now() - lastFetchedAtRef.current < 750) {
return;
}
fetchInFlightRef.current = true;
try {
await onFetchSources();
lastFetchedAtRef.current = Date.now();
} finally {
fetchInFlightRef.current = false;
}
},
[onFetchSources],
);
const prefetchSources = React.useCallback(() => {
if (hasPrefetchedRef.current) {
return;
}
hasPrefetchedRef.current = true;
void fetchSourcesOnce(false);
}, [fetchSourcesOnce]);
// Fetch sources when popover opens
useEffect(() => {
if (open) {
void fetchSourcesOnce(true);
}
}, [open, fetchSourcesOnce]);
// In autonomous mode, we might want to start open
useEffect(() => {
if (isAutonomous) {
setInternalOpen(true);
}
}, [isAutonomous]);
const trigger = children ? (
React.isValidElement(children) ? (
React.cloneElement(children as React.ReactElement<React.HTMLAttributes<HTMLElement>>, {
onPointerEnter: prefetchSources,
onFocusCapture: prefetchSources,
})
) : (
children
)
) : (
<Button
variant="outline"
size="lg"
onPointerEnter={prefetchSources}
onFocusCapture={prefetchSources}
className={cn(
"group gap-2 px-3 min-w-0 max-w-[180px] rounded-[11px] font-medium text-[12px] [ -webkit-app-region:no-drag ] shrink-0",
"border-[#2a2a34] bg-[#1a1a22] text-[#eeeef2] hover:border-[#3e3e4c] hover:bg-[#20202a] transition-all",
"data-[state=open]:border-[#3e3e4c] data-[state=open]:bg-[#20202a]",
)}
title={selectedSource}
>
<MonitorIcon size={16} className="shrink-0" />
<div className="flex-1 min-w-0">
<MarqueeText text={selectedSource} />
</div>
<CaretUpIcon
size={10}
className={cn(
"text-[#6b6b78] ml-0.5 shrink-0 transition-transform duration-200",
open ? "" : "rotate-180",
)}
/>
</Button>
);
const { onMouseEnter } = useHudInteraction();
return (
<Popover open={open} onOpenChange={onOpenChange} modal={false}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="launch-theme w-80 p-0 source-selector-popover"
unstyled
align="start"
sideOffset={8}
side="top"
alignOffset={-8}
avoidCollisions={true}
collisionPadding={10}
usePortal={false}
onMouseEnter={onMouseEnter}
>
<SourceSelectorContent
screenSources={screenSources}
windowSources={windowSources}
selectedSource={selectedSource}
loading={loading}
onSourceSelect={onSourceSelect}
/>
</PopoverContent>
</Popover>
);
});
SourceSelector.displayName = "SourceSelector";
@@ -0,0 +1,16 @@
import { createContext, useContext } from "react";
interface HudInteractionContextType {
onMouseEnter: () => void;
onMouseLeave: (event: any) => void;
}
export const HudInteractionContext = createContext<HudInteractionContextType | null>(null);
export function useHudInteraction() {
const context = useContext(HudInteractionContext);
if (!context) {
throw new Error("useHudInteraction must be used within a HudInteractionProvider");
}
return context;
}
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent, type RefObject } from "react";
import { mergeHudInteractiveBounds, shouldRestoreHudMousePassthroughAfterDrag } from "../hudMousePassthrough";
const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 };
export function useHudBarDrag({
hudContentRef,
hudBarRef,
recordingWebcamPreviewContainerRef,
}: {
hudContentRef: RefObject<HTMLDivElement>;
hudBarRef: RefObject<HTMLDivElement>;
recordingWebcamPreviewContainerRef: RefObject<HTMLDivElement>;
}) {
const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET);
const [isHudDragging, setIsHudDragging] = useState(false);
const hudBarTransformRef = useRef<HTMLDivElement | null>(null);
const recordingHudOffsetRef = useRef(DEFAULT_RECORDING_HUD_OFFSET);
const hudDragStartRef = useRef<
| {
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
initialLeft: number;
initialTop: number;
hudWidth: number;
hudHeight: number;
}
| null
>(null);
const isHudDraggingRef = useRef(false);
const hudDragMoveRafRef = useRef<number | null>(null);
const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
useEffect(() => {
recordingHudOffsetRef.current = recordingHudOffset;
if (!isHudDraggingRef.current && hudBarTransformRef.current) {
hudBarTransformRef.current.style.transform = `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`;
}
}, [recordingHudOffset]);
const handleHudBarPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
isHudDraggingRef.current = true;
setIsHudDragging(true);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
if (!hudBarRef.current) {
return;
}
const hudRect = hudBarRef.current.getBoundingClientRect();
hudDragStartRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: recordingHudOffsetRef.current.x,
originY: recordingHudOffsetRef.current.y,
initialLeft: hudRect.left,
initialTop: hudRect.top,
hudWidth: hudRect.width,
hudHeight: hudRect.height,
};
}, [hudBarRef]);
const handleHudBarPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
const dragState = hudDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
hudDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY };
if (hudDragMoveRafRef.current !== null) {
return;
}
hudDragMoveRafRef.current = requestAnimationFrame(() => {
hudDragMoveRafRef.current = null;
const latestDragState = hudDragStartRef.current;
const pointer = hudDragPendingPointerRef.current;
if (!latestDragState || !pointer) {
return;
}
const deltaX = pointer.clientX - latestDragState.startX;
const deltaY = pointer.clientY - latestDragState.startY;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const unclampedLeft = latestDragState.initialLeft + deltaX;
const unclampedTop = latestDragState.initialTop + deltaY;
const clampedLeft = Math.min(
Math.max(0, unclampedLeft),
Math.max(0, viewportWidth - latestDragState.hudWidth),
);
const clampedTop = Math.min(
Math.max(0, unclampedTop),
Math.max(0, viewportHeight - latestDragState.hudHeight),
);
const nextOffset = {
x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft),
y: latestDragState.originY + (clampedTop - latestDragState.initialTop),
};
recordingHudOffsetRef.current = nextOffset;
if (hudBarTransformRef.current) {
hudBarTransformRef.current.style.transform = `translate3d(${nextOffset.x}px, ${nextOffset.y}px, 0)`;
}
});
}, []);
const handleHudBarPointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
const dragState = hudDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
const pointer = hudDragPendingPointerRef.current || { clientX: event.clientX, clientY: event.clientY };
const deltaX = pointer.clientX - dragState.startX;
const deltaY = pointer.clientY - dragState.startY;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const clampedLeft = Math.min(
Math.max(0, dragState.initialLeft + deltaX),
Math.max(0, viewportWidth - dragState.hudWidth),
);
const clampedTop = Math.min(
Math.max(0, dragState.initialTop + deltaY),
Math.max(0, viewportHeight - dragState.hudHeight),
);
recordingHudOffsetRef.current = {
x: dragState.originX + (clampedLeft - dragState.initialLeft),
y: dragState.originY + (clampedTop - dragState.initialTop),
};
if (hudDragMoveRafRef.current !== null) {
cancelAnimationFrame(hudDragMoveRafRef.current);
hudDragMoveRafRef.current = null;
}
hudDragPendingPointerRef.current = null;
hudDragStartRef.current = null;
const wasDragging = isHudDraggingRef.current;
isHudDraggingRef.current = false;
setRecordingHudOffset({ ...recordingHudOffsetRef.current });
setIsHudDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const hudBounds = mergeHudInteractiveBounds(
[
hudContentRef.current?.getBoundingClientRect(),
hudBarRef.current?.getBoundingClientRect(),
recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(),
].map((bounds) =>
bounds
? {
left: bounds.left,
top: bounds.top,
right: bounds.right,
bottom: bounds.bottom,
}
: null,
),
);
if (wasDragging && shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY)) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef]);
useEffect(() => {
return () => {
if (hudDragMoveRafRef.current !== null) {
cancelAnimationFrame(hudDragMoveRafRef.current);
}
hudDragMoveRafRef.current = null;
hudDragPendingPointerRef.current = null;
hudDragStartRef.current = null;
};
}, []);
return {
recordingHudOffset,
isHudDragging,
hudBarTransformRef,
isHudDraggingRef,
handleHudBarPointerDown,
handleHudBarPointerMove,
handleHudBarPointerUp,
};
}
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useRef, type MouseEvent, type RefObject } from "react";
export function useLaunchHudInteractionState({
openId,
isHudDraggingRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
}: {
openId: string | null;
isHudDraggingRef: RefObject<boolean>;
isWebcamPreviewDraggingRef: RefObject<boolean>;
webcamPreviewDragStartRef: RefObject<unknown>;
}) {
const anyPopoverOpenRef = useRef(false);
const isMouseOverHudRef = useRef(false);
useEffect(() => {
anyPopoverOpenRef.current = openId !== null;
if (openId !== null) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
} else {
// Proactively check if we should ignore mouse when popover closes
setTimeout(() => {
if (!isMouseOverHudRef.current && !anyPopoverOpenRef.current) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, 150);
}
}, [openId]);
const beginInteractiveHudAction = useCallback(() => {
isMouseOverHudRef.current = true;
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
}, []);
const handleHudMouseEnter = useCallback(() => {
isMouseOverHudRef.current = true;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
}, []);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleHudMouseLeave = useCallback((event: MouseEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
return;
}
isMouseOverHudRef.current = false;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
if (
!isHudDraggingRef.current &&
!isWebcamPreviewDraggingRef.current &&
!webcamPreviewDragStartRef.current &&
!isMouseOverHudRef.current &&
!anyPopoverOpenRef.current
) {
// If a popover is open, we can still ignore mouse if the mouse is truly gone,
// but we give a bit more breathing room (the 300ms timeout).
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, 300);
}, [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]);
return {
handleHudMouseEnter,
handleHudMouseLeave,
beginInteractiveHudAction,
};
}
@@ -0,0 +1,71 @@
import { useCallback, useState } from "react";
import type { ProjectLibraryEntry } from "@/components/video-editor/ProjectBrowserDialog";
import type { DesktopSource } from "../popovers/launchPopoverTypes";
export function useLaunchWindowActions() {
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const [projectLibraryEntries, setProjectLibraryEntries] = useState<ProjectLibraryEntry[]>([]);
const handleSourceSelect = useCallback(async (source: DesktopSource) => {
await window.electronAPI.selectSource(source);
setSelectedSource(source.name);
setHasSelectedSource(true);
window.electronAPI.showSourceHighlight?.({
...source,
name: source.appName ? `${source.appName} — ${source.name}` : source.name,
appName: source.appName,
});
}, []);
const openVideoFile = useCallback(async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (result.canceled) return;
if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
await window.electronAPI.switchToEditor();
}
}, []);
const refreshProjectLibrary = useCallback(async () => {
try {
const result = await window.electronAPI.listProjectFiles();
if (!result.success) return;
setProjectLibraryEntries(result.entries);
} catch (error) {
console.error("Failed to load project library:", error);
}
}, []);
const openProjectFromLibrary = useCallback(async (projectPath: string) => {
try {
const result = await window.electronAPI.openProjectFileAtPath(projectPath);
if (result.canceled || !result.success) {
return;
}
await window.electronAPI.switchToEditor();
} catch (error) {
console.error("Failed to open project from library:", error);
}
}, []);
const syncSelectedSource = useCallback((source: { name?: string } | null | undefined) => {
if (source?.name) {
setSelectedSource(source.name);
setHasSelectedSource(true);
return;
}
setSelectedSource("Screen");
setHasSelectedSource(false);
}, []);
return {
selectedSource,
hasSelectedSource,
projectLibraryEntries,
handleSourceSelect,
openVideoFile,
openProjectFromLibrary,
syncSelectedSource,
refreshProjectLibrary,
};
}
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useState } from "react";
export function useLaunchWindowSystemState(
preparePermissions: (args: { startup?: boolean }) => Promise<unknown>,
) {
const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(null);
const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState<
boolean | null
>(null);
const [platform, setPlatform] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [hideHudFromCapture, setHideHudFromCapture] = useState(true);
useEffect(() => {
window.electronAPI?.hudOverlayRendererReady?.();
}, []);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const result = await window.electronAPI.getRecordingsDirectory();
if (!cancelled && result.success) setRecordingsDirectory(result.path);
} catch (error) {
console.error("Failed to load recordings directory:", error);
}
};
void load();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
const loadPlatform = async () => {
try {
const nextPlatform = await window.electronAPI.getPlatform();
if (!cancelled) setPlatform(nextPlatform);
} catch (error) {
console.error("Failed to load platform:", error);
}
};
void loadPlatform();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
const loadSupport = async () => {
try {
const result = await window.electronAPI.getHudOverlayMousePassthroughSupported();
if (!cancelled && result.success) {
setHudOverlayMousePassthroughSupported(result.supported);
}
} catch (error) {
console.error("Failed to load HUD overlay mouse passthrough support:", error);
}
};
void loadSupport();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
void preparePermissions({ startup: true });
}, [preparePermissions]);
useEffect(() => {
let cancelled = false;
const loadVersion = async () => {
try {
const version = await window.electronAPI.getAppVersion();
if (!cancelled) setAppVersion(version);
} catch (error) {
console.error("Failed to load app version:", error);
}
};
void loadVersion();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
const loadCaptureProtection = async () => {
try {
const result = await window.electronAPI.getHudOverlayCaptureProtection();
if (!cancelled && result.success) {
setHideHudFromCapture(result.enabled);
}
} catch (error) {
console.error("Failed to load HUD capture protection state:", error);
}
};
void loadCaptureProtection();
return () => {
cancelled = true;
};
}, []);
const chooseRecordingsDirectory = useCallback(async () => {
try {
const result = await window.electronAPI.chooseRecordingsDirectory();
if (result.canceled) return;
if (result.success && result.path) setRecordingsDirectory(result.path);
} catch (error) {
console.error("Failed to choose recordings directory:", error);
}
}, []);
const toggleHudCaptureProtection = useCallback(async () => {
const nextValue = !hideHudFromCapture;
setHideHudFromCapture(nextValue);
try {
const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue);
if (!result.success) {
setHideHudFromCapture(!nextValue);
return;
}
setHideHudFromCapture(result.enabled);
} catch (error) {
console.error("Failed to update HUD capture protection:", error);
setHideHudFromCapture(!nextValue);
}
}, [hideHudFromCapture]);
return {
recordingsDirectory,
hudOverlayMousePassthroughSupported,
platform,
appVersion,
hideHudFromCapture,
setHideHudFromCapture,
chooseRecordingsDirectory,
toggleHudCaptureProtection,
};
}
@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from "react";
export function useRecordingTimer(recording: boolean, paused: boolean) {
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [pausedAt, setPausedAt] = useState<number | null>(null);
const [pausedTotal, setPausedTotal] = useState(0);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) {
setRecordingStart(Date.now());
setPausedTotal(0);
}
if (paused) {
if (!pausedAt) setPausedAt(Date.now());
if (timer) clearInterval(timer);
} else {
if (pausedAt) {
setPausedTotal((prev) => prev + (Date.now() - pausedAt));
setPausedAt(null);
}
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000));
}
}, 1000);
}
} else {
setRecordingStart(null);
setElapsed(0);
setPausedAt(null);
setPausedTotal(0);
if (timer) clearInterval(timer);
}
return () => {
if (timer) clearInterval(timer);
};
}, [recording, recordingStart, paused, pausedAt, pausedTotal]);
const formatTime = useMemo(
() => (seconds: number) => {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
},
[],
);
return { elapsed, formatTime };
}
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview";
const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6;
const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 };
export function useWebcamPreviewOverlay({
webcamEnabled,
webcamDeviceId,
showWebcamControls,
webcamPopoverOpen,
hudOverlayMousePassthroughSupported,
}: {
webcamEnabled: boolean;
webcamDeviceId?: string;
showWebcamControls: boolean;
webcamPopoverOpen: boolean;
hudOverlayMousePassthroughSupported: boolean | null;
}) {
const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true);
const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET);
const webcamPreviewOffsetRef = useRef(DEFAULT_WEBCAM_PREVIEW_OFFSET);
const webcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const recordingWebcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const recordingWebcamPreviewContainerRef = useRef<HTMLDivElement | null>(null);
const previewStreamRef = useRef<MediaStream | null>(null);
const previewDragMoveRafRef = useRef<number | null>(null);
const previewDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
const webcamPreviewDragStartRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
initialLeft: number;
initialTop: number;
previewWidth: number;
previewHeight: number;
dragging: boolean;
} | null>(null);
const isWebcamPreviewDraggingRef = useRef(false);
const showRecordingWebcamPreview =
webcamEnabled &&
canShowFloatingWebcamPreview(
showFloatingWebcamPreview,
hudOverlayMousePassthroughSupported,
);
const shouldStreamWebcamPreview =
webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamPopoverOpen));
useEffect(() => {
if (!webcamEnabled) {
webcamPreviewOffsetRef.current = DEFAULT_WEBCAM_PREVIEW_OFFSET;
setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET);
if (recordingWebcamPreviewContainerRef.current) {
recordingWebcamPreviewContainerRef.current.style.transform = "translate(0px, 0px)";
}
webcamPreviewDragStartRef.current = null;
isWebcamPreviewDraggingRef.current = false;
setShowFloatingWebcamPreview(true);
}
}, [webcamEnabled]);
const handleWebcamPreviewPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
const previewRect = event.currentTarget.getBoundingClientRect();
event.preventDefault();
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
webcamPreviewDragStartRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: webcamPreviewOffsetRef.current.x,
originY: webcamPreviewOffsetRef.current.y,
initialLeft: previewRect.left,
initialTop: previewRect.top,
previewWidth: previewRect.width,
previewHeight: previewRect.height,
dragging: false,
};
event.currentTarget.setPointerCapture(event.pointerId);
},
[],
);
const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
const dragState = webcamPreviewDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (!dragState.dragging && Math.hypot(deltaX, deltaY) < WEBCAM_PREVIEW_DRAG_THRESHOLD) {
return;
}
if (!dragState.dragging) {
dragState.dragging = true;
isWebcamPreviewDraggingRef.current = true;
}
previewDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY };
if (previewDragMoveRafRef.current !== null) {
return;
}
previewDragMoveRafRef.current = requestAnimationFrame(() => {
previewDragMoveRafRef.current = null;
const latestDragState = webcamPreviewDragStartRef.current;
const pointer = previewDragPendingPointerRef.current;
if (!latestDragState || !pointer) {
return;
}
const latestDeltaX = pointer.clientX - latestDragState.startX;
const latestDeltaY = pointer.clientY - latestDragState.startY;
const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0);
const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0);
const unclampedLeft = latestDragState.initialLeft + latestDeltaX;
const unclampedTop = latestDragState.initialTop + latestDeltaY;
const clampedLeft = Math.min(
Math.max(0, unclampedLeft),
Math.max(0, viewportWidth - latestDragState.previewWidth),
);
const clampedTop = Math.min(
Math.max(0, unclampedTop),
Math.max(0, viewportHeight - latestDragState.previewHeight),
);
const nextOffset = {
x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft),
y: latestDragState.originY + (clampedTop - latestDragState.initialTop),
};
webcamPreviewOffsetRef.current = nextOffset;
if (recordingWebcamPreviewContainerRef.current) {
recordingWebcamPreviewContainerRef.current.style.transform = `translate(${nextOffset.x}px, ${nextOffset.y}px)`;
}
});
}, []);
const handleWebcamPreviewPointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
const dragState = webcamPreviewDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
if (previewDragMoveRafRef.current !== null) {
cancelAnimationFrame(previewDragMoveRafRef.current);
previewDragMoveRafRef.current = null;
}
previewDragPendingPointerRef.current = null;
const wasDragging = dragState.dragging;
webcamPreviewDragStartRef.current = null;
isWebcamPreviewDraggingRef.current = false;
setWebcamPreviewOffset({ ...webcamPreviewOffsetRef.current });
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (wasDragging) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, []);
const attachPreviewStreamToNode = useCallback((videoElement: HTMLVideoElement | null) => {
const previewStream = previewStreamRef.current;
if (!videoElement || !previewStream || videoElement.srcObject === previewStream) {
return;
}
videoElement.srcObject = previewStream;
const playPromise = videoElement.play();
if (playPromise) {
playPromise.catch(() => {
// Ignore autoplay interruptions while the preview element mounts.
});
}
}, []);
const setWebcamPreviewNode = useCallback(
(node: HTMLVideoElement | null) => {
webcamPreviewRef.current = node;
attachPreviewStreamToNode(node);
},
[attachPreviewStreamToNode],
);
const setRecordingWebcamPreviewNode = useCallback(
(node: HTMLVideoElement | null) => {
recordingWebcamPreviewRef.current = node;
attachPreviewStreamToNode(node);
},
[attachPreviewStreamToNode],
);
useEffect(() => {
return () => {
if (previewDragMoveRafRef.current !== null) {
cancelAnimationFrame(previewDragMoveRafRef.current);
}
previewDragMoveRafRef.current = null;
previewDragPendingPointerRef.current = null;
};
}, []);
useEffect(() => {
let mounted = true;
const startPreview = async () => {
if (!shouldStreamWebcamPreview) {
return;
}
try {
const previewStream = await navigator.mediaDevices.getUserMedia({
video: webcamDeviceId
? {
deviceId: { exact: webcamDeviceId },
width: { ideal: 320 },
height: { ideal: 320 },
frameRate: { ideal: 24, max: 30 },
}
: {
width: { ideal: 320 },
height: { ideal: 320 },
frameRate: { ideal: 24, max: 30 },
},
audio: false,
});
if (!mounted) {
previewStream.getTracks().forEach((track) => track.stop());
return;
}
previewStreamRef.current = previewStream;
attachPreviewStreamToNode(webcamPreviewRef.current);
attachPreviewStreamToNode(recordingWebcamPreviewRef.current);
} catch (error) {
console.warn("Failed to start live webcam preview:", error);
}
};
void startPreview();
return () => {
mounted = false;
const previewNode = webcamPreviewRef.current;
const recordingPreviewNode = recordingWebcamPreviewRef.current;
const previewStream = previewStreamRef.current;
[previewNode, recordingPreviewNode]
.filter((node): node is HTMLVideoElement => Boolean(node))
.forEach((videoElement) => {
videoElement.pause();
videoElement.srcObject = null;
});
previewStream?.getTracks().forEach((track) => track.stop());
if (previewStreamRef.current === previewStream) {
previewStreamRef.current = null;
}
};
}, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId]);
return {
showFloatingWebcamPreview,
setShowFloatingWebcamPreview,
webcamPreviewOffset,
recordingWebcamPreviewContainerRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
handleWebcamPreviewPointerDown,
handleWebcamPreviewPointerMove,
handleWebcamPreviewPointerUp,
setWebcamPreviewNode,
setRecordingWebcamPreviewNode,
showRecordingWebcamPreview,
};
}
+40
View File
@@ -0,0 +1,40 @@
.launch-theme {
/* Ultra-Refined Light Mode (Premium OS style) */
--launch-surface: #ffffff;
--launch-panel: #f8fafc;
--launch-border: rgba(0, 0, 0, 0.08);
--launch-border-strong: rgba(0, 0, 0, 0.16);
--launch-text: #020617; /* Deepest Navy/Black */
--launch-text-muted: #475569; /* Slate 600 */
--launch-label: #64748b; /* Slate 500 - increased contrast */
--launch-hover: rgba(0, 0, 0, 0.045);
--launch-selected: rgba(37, 99, 235, 0.12); /* Blue 600 at 12% */
--launch-accent: #2563eb; /* Blue 600 */
--launch-accent-soft: #f1f5f9; /* Slate 100 */
--launch-popover-blur: 40px;
--launch-popover-saturate: 200%;
--launch-bar-bg: rgba(255, 255, 255, 0.98);
--launch-bar-border: rgba(0, 0, 0, 0.08);
--launch-bar-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06);
color: var(--launch-text);
}
.dark .launch-theme,
.launch-theme.dark {
/* Dark Mode values */
--launch-surface: rgba(18, 18, 24, 0.97);
--launch-panel: rgba(18, 18, 24, 0.82);
--launch-border: rgba(255, 255, 255, 0.07);
--launch-border-strong: rgba(255, 255, 255, 0.12);
--launch-text: #f3f6ff;
--launch-text-muted: #aab3c7;
--launch-label: #8f99b1;
--launch-hover: rgba(255, 255, 255, 0.1);
--launch-selected: rgba(92, 153, 255, 0.22);
--launch-accent: #3d8bff;
--launch-accent-soft: #dbeafe;
--launch-bar-bg: rgba(18, 18, 24, 0.97);
--launch-bar-border: rgba(255, 255, 255, 0.07);
--launch-bar-shadow: 0 10px 30px rgba(0, 0, 0, 0.24), 0 2px 10px rgba(0, 0, 0, 0.12);
}
@@ -0,0 +1,53 @@
import { TimerIcon } from "@phosphor-icons/react";
import type { ReactElement } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import styles from "../LaunchWindow.module.css";
import { DropdownItem, HudPopover } from "./PopoverScaffold";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
const POPOVER_ID = "countdown";
const COUNTDOWN_OPTIONS = [0, 3, 5, 10];
export function CountdownPopover({
trigger,
countdownDelay,
onSelectDelay,
}: {
trigger: ReactElement;
countdownDelay: number;
onSelectDelay: (delay: number) => void;
}) {
const t = useScopedT("launch");
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const open = isOpen(POPOVER_ID);
return (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="center"
>
<div className={styles.ddLabel}>{t("recording.countdownDelay")}</div>
{COUNTDOWN_OPTIONS.map((delay) => (
<DropdownItem
key={delay}
icon={<TimerIcon size={16} />}
selected={countdownDelay === delay}
onClick={() => {
onSelectDelay(delay);
requestClose(POPOVER_ID);
}}
>
{delay === 0 ? t("recording.noDelay") : `${delay}s`}
</DropdownItem>
))}
</HudPopover>
);
}
@@ -0,0 +1,48 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
interface LaunchPopoverCoordinatorValue {
openId: string | null;
requestOpen: (id: string) => void;
requestClose: (id: string) => void;
isOpen: (id: string) => boolean;
}
const LaunchPopoverCoordinatorContext = createContext<LaunchPopoverCoordinatorValue | null>(null);
export function LaunchPopoverCoordinatorProvider({ children }: { children: ReactNode }) {
const [openId, setOpenId] = useState<string | null>(null);
const requestOpen = useCallback((id: string) => {
setOpenId(id);
}, []);
const requestClose = useCallback((id: string) => {
setOpenId((currentId) => (currentId === id ? null : currentId));
}, []);
const isOpen = useCallback((id: string) => openId === id, [openId]);
const value = useMemo(
() => ({
openId,
requestOpen,
requestClose,
isOpen,
}),
[isOpen, openId, requestClose, requestOpen],
);
return (
<LaunchPopoverCoordinatorContext.Provider value={value}>
{children}
</LaunchPopoverCoordinatorContext.Provider>
);
}
export function useLaunchPopoverCoordinator() {
const context = useContext(LaunchPopoverCoordinatorContext);
if (!context) {
throw new Error("useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider");
}
return context;
}
@@ -0,0 +1,94 @@
import { MicrophoneSlashIcon, SpeakerHighIcon, SpeakerXIcon } from "@phosphor-icons/react";
import { useScopedT } from "@/contexts/I18nContext";
import { DropdownItem, HudPopover, MicDeviceRow } from "./PopoverScaffold";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import type { DeviceOption } from "./launchPopoverTypes";
import type { ReactElement } from "react";
import styles from "../LaunchWindow.module.css";
const POPOVER_ID = "mic";
export function MicPopover({
trigger,
disabled,
systemAudioEnabled,
onToggleSystemAudio,
microphoneEnabled,
onDisableMicrophone,
devices,
microphoneDeviceId,
selectedDeviceId,
onSelectDevice,
}: {
trigger: ReactElement;
disabled?: boolean;
systemAudioEnabled: boolean;
onToggleSystemAudio: () => void;
microphoneEnabled: boolean;
onDisableMicrophone: () => void;
devices: DeviceOption[];
microphoneDeviceId?: string;
selectedDeviceId?: string;
onSelectDevice: (deviceId: string) => void;
}) {
const t = useScopedT("launch");
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const open = isOpen(POPOVER_ID);
return (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
if (disabled) {
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="start"
>
<div className={styles.ddLabel}>{t("recording.microphone")}</div>
<DropdownItem
icon={systemAudioEnabled ? <SpeakerHighIcon size={16} /> : <SpeakerXIcon size={16} />}
selected={systemAudioEnabled}
onClick={onToggleSystemAudio}
>
{systemAudioEnabled
? t("recording.disableSystemAudio")
: t("recording.enableSystemAudio")}
</DropdownItem>
{microphoneEnabled && (
<DropdownItem
icon={<MicrophoneSlashIcon size={16} />}
onClick={() => {
onDisableMicrophone();
requestClose(POPOVER_ID);
}}
>
{t("recording.turnOffMicrophone")}
</DropdownItem>
)}
{!microphoneEnabled && (
<div className="px-3 py-2 text-xs text-[var(--launch-text-muted)]">{t("recording.selectMicToEnable")}</div>
)}
{devices.map((device) => (
<MicDeviceRow
key={device.deviceId}
device={device}
selected={
microphoneEnabled &&
(microphoneDeviceId === device.deviceId || selectedDeviceId === device.deviceId)
}
onSelect={() => onSelectDevice(device.deviceId)}
/>
))}
{devices.length === 0 && (
<div className="text-center text-xs text-[var(--launch-text-muted)] py-4">{t("recording.noMicrophonesFound")}</div>
)}
</HudPopover>
);
}
@@ -0,0 +1,191 @@
import {
EyeIcon,
EyeSlashIcon,
FolderOpenIcon,
TranslateIcon,
VideoCameraIcon,
ArrowClockwiseIcon,
SunIcon,
MoonIcon,
DesktopIcon,
} from "@phosphor-icons/react";
import type { ReactElement } from "react";
import { useI18n } from "@/contexts/I18nContext";
import { useScopedT } from "@/contexts/I18nContext";
import { useTheme } from "@/contexts/ThemeContext";
import type { AppLocale } from "@/i18n/config";
import { SUPPORTED_LOCALES } from "@/i18n/config";
import styles from "../LaunchWindow.module.css";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import { DropdownItem, HudPopover } from "./PopoverScaffold";
const POPOVER_ID = "more";
const LOCALE_LABELS: Record<string, string> = {
en: "English",
es: "Español",
fr: "Français",
nl: "Nederlands",
ko: "한국어",
"pt-BR": "Português",
"zh-CN": "簡體中文",
"zh-TW": "繁體中文",
};
export function MorePopover({
trigger,
supportsHudCaptureProtection,
hideHudFromCapture,
onToggleHudCaptureProtection,
onChooseRecordingsDirectory,
onOpenVideoFile,
onOpenProjectBrowser,
showDevUpdatePreview,
onPreviewUpdateUi,
appVersion,
}: {
trigger: ReactElement;
supportsHudCaptureProtection: boolean;
hideHudFromCapture: boolean;
onToggleHudCaptureProtection: () => void;
onChooseRecordingsDirectory: () => void;
onOpenVideoFile: () => void;
onOpenProjectBrowser: () => void;
showDevUpdatePreview: boolean;
onPreviewUpdateUi: () => void;
appVersion: string | null;
}) {
const t = useScopedT("launch");
const { locale, setLocale } = useI18n();
const { preference, setPreference } = useTheme();
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const open = isOpen(POPOVER_ID);
return (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="end"
>
{supportsHudCaptureProtection && (
<DropdownItem
icon={hideHudFromCapture ? <EyeSlashIcon size={16} /> : <EyeIcon size={16} />}
selected={hideHudFromCapture}
onClick={onToggleHudCaptureProtection}
>
{hideHudFromCapture
? t("recording.hideHudFromVideo")
: t("recording.showHudInVideo")}
</DropdownItem>
)}
<DropdownItem
icon={<FolderOpenIcon size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onChooseRecordingsDirectory();
}}
>
{t("recording.recordingsFolder")}
</DropdownItem>
<DropdownItem
icon={<VideoCameraIcon size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onOpenVideoFile();
}}
>
{t("recording.openVideoFile")}
</DropdownItem>
<DropdownItem
icon={<FolderOpenIcon size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onOpenProjectBrowser();
}}
>
{t("recording.openProject")}
</DropdownItem>
{showDevUpdatePreview ? (
<DropdownItem
icon={<ArrowClockwiseIcon size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onPreviewUpdateUi();
}}
>
{t("recording.previewUpdateUi", "Preview Update UI")}
</DropdownItem>
) : null}
<div className={styles.ddLabel} style={{ marginTop: 4 }}>
{t("recording.appearance", "Appearance")}
</div>
<DropdownItem
icon={<SunIcon size={16} />}
selected={preference === "light"}
onClick={() => {
setPreference("light");
requestClose(POPOVER_ID);
}}
>
{t("common.light", "Light")}
</DropdownItem>
<DropdownItem
icon={<MoonIcon size={16} />}
selected={preference === "dark"}
onClick={() => {
setPreference("dark");
requestClose(POPOVER_ID);
}}
>
{t("common.dark", "Dark")}
</DropdownItem>
<DropdownItem
icon={<DesktopIcon size={16} />}
selected={preference === "system"}
onClick={() => {
setPreference("system");
requestClose(POPOVER_ID);
}}
>
{t("common.system", "System")}
</DropdownItem>
<div className={styles.ddLabel} style={{ marginTop: 4 }}>
{t("recording.language")}
</div>
{SUPPORTED_LOCALES.map((code) => (
<DropdownItem
key={code}
icon={<TranslateIcon size={16} />}
selected={locale === code}
onClick={() => {
setLocale(code as AppLocale);
requestClose(POPOVER_ID);
}}
>
{LOCALE_LABELS[code] ?? code}
</DropdownItem>
))}
{appVersion && (
<div
style={{
marginTop: 8,
padding: "4px 12px",
fontSize: 11,
color: "var(--launch-text-muted)",
textAlign: "center",
userSelect: "text",
}}
>
v{appVersion}
</div>
)}
</HudPopover>
);
}
@@ -0,0 +1,96 @@
import { MicrophoneIcon, MicrophoneSlashIcon } from "@phosphor-icons/react";
import type { ReactElement, ReactNode } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter";
import { AudioLevelMeter } from "@/components/ui/audio-level-meter";
import styles from "../LaunchWindow.module.css";
import "../launchTheme.css";
import type { DeviceOption } from "./launchPopoverTypes";
import { useHudInteraction } from "../contexts/HudInteractionContext";
export function DropdownItem({
onClick,
selected,
icon,
children,
trailing,
}: {
onClick: () => void;
selected?: boolean;
icon: ReactNode;
children: ReactNode;
trailing?: ReactNode;
}) {
return (
<button
type="button"
className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`}
onClick={onClick}
>
<span className="shrink-0">{icon}</span>
<span className="truncate">{children}</span>
{trailing}
</button>
);
}
export function MicDeviceRow({
device,
selected,
onSelect,
}: {
device: DeviceOption;
selected: boolean;
onSelect: () => void;
}) {
const { level } = useAudioLevelMeter({
enabled: true,
deviceId: device.deviceId,
});
return (
<button
type="button"
className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`}
onClick={onSelect}
>
<span className="shrink-0">{selected ? <MicrophoneIcon size={16} /> : <MicrophoneSlashIcon size={16} />}</span>
<span className="truncate flex-1">{device.label}</span>
<AudioLevelMeter level={level} className="w-16 shrink-0" />
</button>
);
}
export function HudPopover({
open,
onOpenChange,
trigger,
children,
align = "center",
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: ReactElement;
children: ReactNode;
align?: "start" | "center" | "end";
}) {
const { onMouseEnter } = useHudInteraction();
return (
<Popover open={open} onOpenChange={onOpenChange} modal={false}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className={`launch-theme ${styles.menuCard} ${styles.electronNoDrag}`}
unstyled
side="top"
align={align}
sideOffset={8}
avoidCollisions
collisionPadding={10}
usePortal={false}
onMouseEnter={onMouseEnter}
>
{children}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,48 @@
import type { ReactElement } from "react";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import { HudPopover } from "./PopoverScaffold";
import ProjectBrowserDialog from "../../video-editor/ProjectBrowserDialog";
import type { ProjectLibraryEntry } from "../../video-editor/ProjectBrowserDialog";
const POPOVER_ID = "projects";
export function ProjectPopover({
trigger,
entries,
onOpenProject,
}: {
trigger: ReactElement;
entries: ProjectLibraryEntry[];
onOpenProject: (projectPath: string) => void;
}) {
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const open = isOpen(POPOVER_ID);
return (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="center"
>
<ProjectBrowserDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) requestClose(POPOVER_ID);
}}
entries={entries}
renderMode="inline"
onOpenProject={(path) => {
onOpenProject(path);
requestClose(POPOVER_ID);
}}
/>
</HudPopover>
);
}
@@ -0,0 +1,77 @@
import { useCallback, useMemo, type ReactNode, useState } from "react";
import { SourceSelector } from "../SourceSelector";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import {
mapRawSource,
isScreenSource,
isWindowSource,
type DesktopSource,
} from "./launchPopoverTypes";
const POPOVER_ID = "sources";
export function SourcePopover({
trigger,
selectedSource,
onSourceSelect,
onOpen,
}: {
trigger: ReactNode;
selectedSource: string;
onSourceSelect: (source: DesktopSource) => Promise<void> | void;
onOpen?: () => void;
}) {
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const [sources, setSources] = useState<DesktopSource[]>([]);
const [loading, setLoading] = useState(false);
const open = isOpen(POPOVER_ID);
const fetchSources = useCallback(async () => {
if (!window.electronAPI) return;
setLoading(true);
try {
const rawSources = await window.electronAPI.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 160, height: 90 },
fetchWindowIcons: true,
});
setSources(rawSources.map((s) => mapRawSource(s as DesktopSource)));
} catch (error) {
console.error("Failed to fetch sources:", error);
} finally {
setLoading(false);
}
}, []);
const screenSources = useMemo(() => sources.filter(isScreenSource), [sources]);
const windowSources = useMemo(() => sources.filter(isWindowSource), [sources]);
return (
<SourceSelector
screenSources={screenSources}
windowSources={windowSources}
selectedSource={selectedSource}
loading={loading}
onSourceSelect={async (source) => {
try {
await onSourceSelect(source);
requestClose(POPOVER_ID);
} catch (error) {
console.error("Failed to select source:", error);
}
}}
onFetchSources={fetchSources}
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
onOpen?.();
requestOpen(POPOVER_ID);
}}
>
{trigger}
</SourceSelector>
);
}
@@ -0,0 +1,129 @@
import {
Eye,
EyeSlash as EyeOff,
VideoCamera as Video,
VideoCameraSlash as VideoOff,
} from "@phosphor-icons/react";
import { useScopedT } from "@/contexts/I18nContext";
import { DropdownItem, HudPopover } from "./PopoverScaffold";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import type { DeviceOption } from "./launchPopoverTypes";
import type { ReactElement } from "react";
const POPOVER_ID = "webcam";
export function WebcamPopover({
trigger,
disabled,
webcamEnabled,
onDisableWebcam,
canToggleFloatingPreview,
showFloatingWebcamPreview,
onToggleFloatingPreview,
showWebcamControls,
setWebcamPreviewNode,
videoDevices,
webcamDeviceId,
selectedVideoDeviceId,
onSelectVideoDevice,
}: {
trigger: ReactElement;
disabled?: boolean;
webcamEnabled: boolean;
onDisableWebcam: () => void;
canToggleFloatingPreview: boolean;
showFloatingWebcamPreview: boolean;
onToggleFloatingPreview: () => void;
showWebcamControls: boolean;
setWebcamPreviewNode: (node: HTMLVideoElement | null) => void;
videoDevices: DeviceOption[];
webcamDeviceId?: string;
selectedVideoDeviceId?: string;
onSelectVideoDevice: (deviceId: string) => void;
}) {
const t = useScopedT("launch");
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const open = isOpen(POPOVER_ID);
return (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
if (disabled) {
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="center"
>
<div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--launch-label)]">
{t("recording.webcam")}
</div>
{webcamEnabled && (
<>
<DropdownItem icon={<VideoOff size={16} />} onClick={() => {
onDisableWebcam();
requestClose(POPOVER_ID);
}}>
{t("recording.turnOffWebcam")}
</DropdownItem>
{canToggleFloatingPreview ? (
<DropdownItem
icon={showFloatingWebcamPreview ? <EyeOff size={16} /> : <Eye size={16} />}
selected={showFloatingWebcamPreview}
onClick={onToggleFloatingPreview}
>
{showFloatingWebcamPreview
? t("recording.hideFloatingWebcamPreview")
: t("recording.showFloatingWebcamPreview")}
</DropdownItem>
) : null}
</>
)}
{!webcamEnabled && (
<div className="px-3 py-2 text-xs text-[var(--launch-text-muted)]">{t("recording.selectWebcamToEnable")}</div>
)}
{showWebcamControls && (
<div className="flex justify-center px-3 py-2">
<div className="h-24 w-24 overflow-hidden rounded-2xl bg-[var(--launch-hover)] ring-1 ring-[var(--launch-border-strong)]">
<video
ref={setWebcamPreviewNode}
className="h-full w-full object-cover"
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
</div>
)}
{videoDevices.map((device) => (
<DropdownItem
key={device.deviceId}
icon={
webcamEnabled &&
(webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId) ? (
<Video size={16} />
) : (
<VideoOff size={16} />
)
}
selected={
webcamEnabled &&
(webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId)
}
onClick={() => onSelectVideoDevice(device.deviceId)}
>
{device.label}
</DropdownItem>
))}
{videoDevices.length === 0 && (
<div className="text-center text-xs text-[var(--launch-text-muted)] py-4">{t("recording.noWebcamsFound")}</div>
)}
</HudPopover>
);
}
@@ -0,0 +1,53 @@
export interface DesktopSource {
id: string;
name: string;
thumbnail: string | null;
display_id: string;
appIcon: string | null;
sourceType?: "screen" | "window";
appName?: string;
windowTitle?: string;
}
/**
* Check if a source is a screen/display
*/
export function isScreenSource(s: DesktopSource): boolean {
return s.sourceType === "screen" || s.id.startsWith("screen:");
}
/**
* Check if a source is an application window
*/
export function isWindowSource(s: DesktopSource): boolean {
return s.sourceType === "window" || s.id.startsWith("window:");
}
export function mapRawSource(s: DesktopSource): DesktopSource {
const isWindow = isWindowSource(s);
const type = s.sourceType ?? (isWindow ? "window" : "screen");
let displayName = s.name;
let appName = s.appName;
if (isWindow && s.windowTitle) {
displayName = s.windowTitle;
} else if (isWindow && !appName && s.name.includes(" — ")) {
const parts = s.name.split(" — ");
appName = parts[0]?.trim();
displayName = parts.slice(1).join(" — ").trim() || s.name;
}
return {
id: s.id,
name: displayName,
thumbnail: s.thumbnail,
display_id: s.display_id,
appIcon: s.appIcon,
sourceType: type,
appName,
windowTitle: s.windowTitle ?? displayName,
};
}
export interface DeviceOption {
deviceId: string;
label: string;
}
+15 -3
View File
@@ -5,7 +5,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -24,10 +24,18 @@ const buttonVariants = cva(
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
// Special variant for icon buttons with consistent sizing
iconSize: {
default: "[&_svg]:size-4",
sm: "[&_svg]:size-3.5",
lg: "[&_svg]:size-5",
xl: "[&_svg]:size-6",
},
},
defaultVariants: {
variant: "default",
size: "default",
iconSize: "default",
},
},
);
@@ -35,15 +43,18 @@ const buttonVariants = cva(
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
/** Whether to render the button as a child component (useful for composition) */
asChild?: boolean;
/** Size of the icon inside the button */
iconSize?: "default" | "sm" | "lg" | "xl";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
({ className, variant, size, iconSize, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ variant, size, iconSize, className }))}
ref={ref}
{...props}
/>
@@ -53,3 +64,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
Button.displayName = "Button";
export { Button, buttonVariants };
+24 -14
View File
@@ -18,26 +18,36 @@ function PopoverContent({
align = "center",
sideOffset = 4,
animated = true,
usePortal = true,
unstyled = false,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
animated?: boolean;
usePortal?: boolean;
unstyled?: boolean;
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
const content = (
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
!unstyled &&
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
animated &&
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
unstyled && "z-50 outline-hidden",
animated &&
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
className,
)}
{...props}
/>
);
if (usePortal === false) {
return content;
}
return <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>;
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+29
View File
@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
interface SeparatorProps {
/** Orientation of the separator */
orientation?: "horizontal" | "vertical";
/** Additional CSS classes */
className?: string;
}
/**
* A reusable separator component that can be used as a horizontal or vertical divider.
* Replaces the inline div separators used throughout the application.
*/
export function Separator({
orientation = "horizontal",
className,
}: SeparatorProps) {
return (
<div
role="separator"
aria-orientation={orientation}
className={cn(
"bg-separator",
orientation === "horizontal" ? "h-px w-full" : "w-px h-full",
className,
)}
/>
);
}
+3
View File
@@ -4,6 +4,7 @@
"enableSystemAudio": "Enable system audio",
"disableMicrophone": "Disable microphone",
"enableMicrophone": "Enable microphone",
"micToggleDisabledTip": "Microphone cannot be toggled during recording",
"disableWebcam": "Disable webcam overlay",
"enableWebcam": "Enable webcam overlay",
"countdownDelay": "Countdown delay",
@@ -20,6 +21,8 @@
"closeApp": "Close App",
"screens": "Screens",
"windows": "Windows",
"screen": "Screen",
"window": "Window",
"noSourcesFound": "No sources found",
"microphone": "Microphone",
"turnOffMicrophone": "Turn Off Microphone",
+3
View File
@@ -4,6 +4,7 @@
"enableSystemAudio": "Activar audio del sistema",
"disableMicrophone": "Desactivar micrófono",
"enableMicrophone": "Activar micrófono",
"micToggleDisabledTip": "El micrófono no se puede alternar durante la grabación",
"disableWebcam": "Desactivar superposición de cámara",
"enableWebcam": "Activar superposición de cámara",
"countdownDelay": "Retraso de cuenta regresiva",
@@ -20,6 +21,8 @@
"closeApp": "Cerrar aplicación",
"screens": "Pantallas",
"windows": "Ventanas",
"screen": "Pantalla",
"window": "Ventana",
"noSourcesFound": "No se encontraron fuentes",
"microphone": "Micrófono",
"turnOffMicrophone": "Desactivar micrófono",
+3
View File
@@ -4,6 +4,7 @@
"enableSystemAudio": "Activer l’audio système",
"disableMicrophone": "Désactiver le microphone",
"enableMicrophone": "Activer le microphone",
"micToggleDisabledTip": "Le microphone ne peut pas être basculé pendant l'enregistrement",
"disableWebcam": "Désactiver l’incrustation webcam",
"enableWebcam": "Activer l’incrustation webcam",
"countdownDelay": "Délai du compte à rebours",
@@ -20,6 +21,8 @@
"closeApp": "Fermer l’application",
"screens": "Écrans",
"windows": "Fenêtres",
"screen": "Écran",
"window": "Fenêtre",
"noSourcesFound": "Aucune source trouvée",
"microphone": "Microphone",
"turnOffMicrophone": "Désactiver le microphone",
+3
View File
@@ -4,6 +4,7 @@
"enableSystemAudio": "시스템 오디오 켜기",
"disableMicrophone": "마이크 끄기",
"enableMicrophone": "마이크 켜기",
"micToggleDisabledTip": "녹음 중에는 마이크를 전환할 수 없습니다",
"disableWebcam": "웹캠 오버레이 끄기",
"enableWebcam": "웹캠 오버레이 켜기",
"countdownDelay": "카운트다운 지연",
@@ -20,6 +21,8 @@
"closeApp": "앱 닫기",
"screens": "화면",
"windows": "창",
"screen": "화면",
"window": "창",
"noSourcesFound": "사용 가능한 소스를 찾을 수 없습니다",
"microphone": "마이크",
"turnOffMicrophone": "마이크 끄기",
+3
View File
@@ -4,6 +4,7 @@
"enableSystemAudio": "Systeemaudio inschakelen",
"disableMicrophone": "Microfoon uitschakelen",
"enableMicrophone": "Microfoon inschakelen",
"micToggleDisabledTip": "Microfoon kan niet worden gewijzigd tijdens de opname",
"disableWebcam": "Webcam-overlay uitschakelen",
"enableWebcam": "Webcam-overlay inschakelen",
"countdownDelay": "Aftelvertraging",
@@ -20,6 +21,8 @@
"closeApp": "App sluiten",
"screens": "Schermen",
"windows": "Vensters",
"screen": "Scherm",
"window": "Venster",
"noSourcesFound": "Geen bronnen gevonden",
"microphone": "Microfoon",
"turnOffMicrophone": "Microfoon uitschakelen",
+3
View File
@@ -4,6 +4,7 @@
"enableSystemAudio": "Ativar áudio do sistema",
"disableMicrophone": "Desativar microfone",
"enableMicrophone": "Ativar microfone",
"micToggleDisabledTip": "O microfone não pode ser alternado durante a gravação",
"disableWebcam": "Desativar sobreposição da webcam",
"enableWebcam": "Ativar sobreposição da webcam",
"countdownDelay": "Atraso da contagem regressiva",
@@ -20,6 +21,8 @@
"closeApp": "Fechar app",
"screens": "Telas",
"windows": "Janelas",
"screen": "Tela",
"window": "Janela",
"noSourcesFound": "Nenhuma fonte encontrada",
"microphone": "Microfone",
"turnOffMicrophone": "Desligar microfone",
+3
View File
@@ -4,6 +4,7 @@
"enableSystemAudio": "启用系统音频",
"disableMicrophone": "禁用麦克风",
"enableMicrophone": "启用麦克风",
"micToggleDisabledTip": "录制过程中无法切换麦克风",
"disableWebcam": "禁用摄像头叠加",
"enableWebcam": "启用摄像头叠加",
"countdownDelay": "倒计时延迟",
@@ -20,6 +21,8 @@
"closeApp": "关闭应用",
"screens": "屏幕",
"windows": "窗口",
"screen": "屏幕",
"window": "窗口",
"noSourcesFound": "未找到源",
"microphone": "麦克风",
"turnOffMicrophone": "关闭麦克风",
+4 -1
View File
@@ -4,6 +4,7 @@
"enableSystemAudio": "啟用系統音訊",
"disableMicrophone": "停用麥克風",
"enableMicrophone": "啟用麥克風",
"micToggleDisabledTip": "錄製過程中無法切換麥克風",
"disableWebcam": "停用網路攝影機疊加",
"enableWebcam": "啟用網路攝影機疊加",
"countdownDelay": "倒數延遲",
@@ -20,6 +21,8 @@
"closeApp": "關閉應用程式",
"screens": "螢幕",
"windows": "視窗",
"screen": "螢幕",
"window": "視窗",
"noSourcesFound": "找不到可錄製的來源",
"microphone": "麥克風",
"turnOffMicrophone": "關閉麥克風",
@@ -74,4 +77,4 @@
"failedToStart": "無法開始錄製:{{error}}",
"failedToStartGeneric": "無法開始錄製"
}
}
}
+9
View File
@@ -22,6 +22,12 @@
margin: 0;
}
html.hud-overlay-window,
body.hud-overlay-window,
#root.hud-overlay-window {
overflow: hidden;
}
:root {
--app-font-sans: "SF Pro Display", "SF Pro Text", Helvetica, sans-serif;
--brand-accent: #2563eb;
@@ -262,3 +268,6 @@
transform: translateX(270%);
}
}