mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 08:15:42 +00:00
Merge pull request #431 from ExtraBinoss/feat/launchwindow-hud-refactor
feat: LaunchWindow refactoring, SourceSelector performance
This commit is contained in:
Vendored
+1
-3
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
+303
-1386
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
};
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"enableSystemAudio": "시스템 오디오 켜기",
|
||||
"disableMicrophone": "마이크 끄기",
|
||||
"enableMicrophone": "마이크 켜기",
|
||||
"micToggleDisabledTip": "녹음 중에는 마이크를 전환할 수 없습니다",
|
||||
"disableWebcam": "웹캠 오버레이 끄기",
|
||||
"enableWebcam": "웹캠 오버레이 켜기",
|
||||
"countdownDelay": "카운트다운 지연",
|
||||
@@ -20,6 +21,8 @@
|
||||
"closeApp": "앱 닫기",
|
||||
"screens": "화면",
|
||||
"windows": "창",
|
||||
"screen": "화면",
|
||||
"window": "창",
|
||||
"noSourcesFound": "사용 가능한 소스를 찾을 수 없습니다",
|
||||
"microphone": "마이크",
|
||||
"turnOffMicrophone": "마이크 끄기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"enableSystemAudio": "启用系统音频",
|
||||
"disableMicrophone": "禁用麦克风",
|
||||
"enableMicrophone": "启用麦克风",
|
||||
"micToggleDisabledTip": "录制过程中无法切换麦克风",
|
||||
"disableWebcam": "禁用摄像头叠加",
|
||||
"enableWebcam": "启用摄像头叠加",
|
||||
"countdownDelay": "倒计时延迟",
|
||||
@@ -20,6 +21,8 @@
|
||||
"closeApp": "关闭应用",
|
||||
"screens": "屏幕",
|
||||
"windows": "窗口",
|
||||
"screen": "屏幕",
|
||||
"window": "窗口",
|
||||
"noSourcesFound": "未找到源",
|
||||
"microphone": "麦克风",
|
||||
"turnOffMicrophone": "关闭麦克风",
|
||||
|
||||
@@ -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": "無法開始錄製"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user