Merge pull request #648 from Arunsiva003/feature/vertical-menu-tray-layout

Add vertical tray layout toggle
This commit is contained in:
Sid
2026-05-30 14:04:23 -07:00
committed by GitHub
15 changed files with 138 additions and 26 deletions
+74 -12
View File
@@ -1,4 +1,4 @@
import { Check, ChevronDown, Languages } from "lucide-react";
import { Check, ChevronDown, Columns3, Languages, Rows3 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
@@ -27,6 +27,7 @@ import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { requestCameraAccess } from "../../lib/requestCameraAccess";
import { loadUserPreferences, saveUserPreferences } from "../../lib/userPreferences";
import { formatTimePadded } from "../../utils/timeUtils";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { Button } from "../ui/button";
@@ -59,6 +60,7 @@ const ICON_CONFIG = {
type IconName = keyof typeof ICON_CONFIG;
/** Renders the configured icon for a HUD control. */
function getIcon(name: IconName, className?: string) {
const { icon: Icon, size } = ICON_CONFIG[name];
return <Icon size={size} className={className} />;
@@ -77,7 +79,10 @@ const windowBtnClasses =
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
const hudSidebarVerticalClasses =
"mt-0.5 pt-1.5 border-t border-white/10 flex flex-col items-center gap-0.5";
/** Launches the floating recording HUD and its recorder controls. */
export function LaunchWindow() {
const t = useScopedT("launch");
const availableLocales = getAvailableLocales();
@@ -128,6 +133,9 @@ export function LaunchWindow() {
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
const [trayLayout, setTrayLayout] = useState<"horizontal" | "vertical">(
() => loadUserPreferences().trayLayout,
);
const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false);
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
@@ -365,6 +373,12 @@ export function LaunchWindow() {
window.electronAPI.hudOverlayClose();
}
};
/** Switches the HUD between horizontal and vertical tray layouts. */
const toggleTrayLayout = () => {
const nextLayout = trayLayout === "horizontal" ? "vertical" : "horizontal";
setTrayLayout(nextLayout);
saveUserPreferences({ trayLayout: nextLayout });
};
const toggleMicrophone = () => {
if (!recording) {
@@ -589,7 +603,12 @@ export function LaunchWindow() {
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
<div
data-hud-interactive="true"
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
data-tray-layout={trayLayout}
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex rounded-2xl border border-white/[0.10] bg-[#07080a]/90 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%] ${
trayLayout === "vertical"
? "max-h-[calc(100vh-2.5rem)] flex-col items-center gap-1 overflow-y-auto px-1 py-1.5"
: "items-center gap-1.5 px-2 py-1.5"
}`}
onPointerEnter={() => setHudMouseEventsEnabled(true)}
onPointerDown={() => setHudMouseEventsEnabled(true)}
onMouseEnter={() => setHudMouseEventsEnabled(true)}
@@ -601,7 +620,7 @@ export function LaunchWindow() {
>
{/* Drag handle */}
<div
className={`flex h-8 w-7 cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
className={`flex ${trayLayout === "vertical" ? "h-6 w-8" : "h-8 w-7"} cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
onPointerDown={handleHudDragPointerDown}
onPointerMove={handleHudDragPointerMove}
onPointerUp={handleHudDragPointerEnd}
@@ -610,21 +629,53 @@ export function LaunchWindow() {
{getIcon("drag", "text-white/30")}
</div>
<Tooltip
content={
trayLayout === "horizontal"
? t("tooltips.useVerticalTray")
: t("tooltips.useHorizontalTray")
}
>
<button
data-testid="launch-tray-layout-button"
type="button"
aria-label={
trayLayout === "horizontal"
? t("tooltips.useVerticalTray")
: t("tooltips.useHorizontalTray")
}
aria-pressed={trayLayout === "vertical"}
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={toggleTrayLayout}
>
{trayLayout === "horizontal" ? (
<Columns3 size={ICON_SIZE} className="text-white/60" />
) : (
<Rows3 size={ICON_SIZE} className="text-white/60" />
)}
</button>
</Tooltip>
{/* Source selector */}
<button
className={`${hudGroupClasses} h-8 px-2.5 ${styles.electronNoDrag}`}
className={`${hudGroupClasses} h-8 ${trayLayout === "vertical" ? "w-8 justify-center px-0" : "px-2.5"} ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
aria-label={selectedSource}
>
{getIcon("monitor", "text-white/80")}
<span className="max-w-[86px] truncate text-[11px] font-medium text-white/75">
<span
className={`${trayLayout === "vertical" ? "sr-only" : "max-w-[86px]"} truncate text-[11px] font-medium text-white/75`}
>
{selectedSource}
</span>
</button>
{/* Audio controls group */}
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
<div
className={`${hudGroupClasses} ${trayLayout === "vertical" ? "flex-col py-1" : ""} ${styles.electronNoDrag}`}
>
<button
data-testid="launch-system-audio-button"
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
@@ -697,7 +748,7 @@ export function LaunchWindow() {
{/* Record/Stop group */}
<button
data-testid="launch-record-button"
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${trayLayout === "vertical" ? "min-h-9" : ""} ${styles.electronNoDrag} ${
recording
? paused
? "bg-amber-500/10 hover:bg-amber-500/15"
@@ -723,7 +774,9 @@ export function LaunchWindow() {
</button>
{recording && (
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<div
className={`flex items-center gap-0.5 ${trayLayout === "vertical" ? "flex-col" : ""} ${styles.electronNoDrag}`}
>
{canPauseRecording && (
<Tooltip
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
@@ -776,7 +829,9 @@ export function LaunchWindow() {
)}
{/* Right sidebar controls */}
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
<div
className={`${trayLayout === "vertical" ? hudSidebarVerticalClasses : hudSidebarClasses} ${styles.electronNoDrag}`}
>
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
<button
ref={languageTriggerRef}
@@ -785,10 +840,15 @@ export function LaunchWindow() {
aria-expanded={isLanguageMenuOpen}
aria-haspopup="menu"
onClick={() => setIsLanguageMenuOpen((open) => !open)}
className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
title={activeLanguageLabel}
className={`flex h-8 items-center rounded-lg border border-white/10 bg-white/[0.045] text-white/85 shadow-none transition-colors hover:bg-white/10 ${
trayLayout === "vertical" ? "w-8 justify-center px-0" : "gap-1.5 px-2"
} ${styles.electronNoDrag}`}
>
<Languages size={13} className="text-white/70" />
<span className="max-w-[54px] truncate text-[10px] font-semibold text-white/75">
<span
className={`${trayLayout === "vertical" ? "sr-only" : "max-w-[54px]"} truncate text-[10px] font-semibold text-white/75`}
>
{activeLanguageLabel}
</span>
</button>
@@ -841,7 +901,9 @@ export function LaunchWindow() {
: null}
{/* Window controls */}
<div className="flex items-center gap-0.5">
<div
className={`flex items-center gap-0.5 ${trayLayout === "vertical" ? "flex-col" : ""}`}
>
<button
className={windowBtnClasses}
title={t("tooltips.hideHUD")}
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "إيقاف التسجيل مؤقتاً",
"resumeRecording": "استئناف التسجيل",
"openVideoFile": "فتح ملف فيديو",
"openProject": "فتح مشروع"
"openProject": "فتح مشروع",
"useVerticalTray": "استخدام الشريط العمودي",
"useHorizontalTray": "استخدام الشريط الأفقي"
},
"audio": {
"enableSystemAudio": "تفعيل صوت النظام",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "Pause recording",
"resumeRecording": "Resume recording",
"openVideoFile": "Open video file",
"openProject": "Open project"
"openProject": "Open project",
"useVerticalTray": "Use vertical tray",
"useHorizontalTray": "Use horizontal tray"
},
"audio": {
"enableSystemAudio": "Enable system audio",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "Pausar grabación",
"resumeRecording": "Reanudar grabación",
"openVideoFile": "Abrir archivo de video",
"openProject": "Abrir proyecto"
"openProject": "Abrir proyecto",
"useVerticalTray": "Usar bandeja vertical",
"useHorizontalTray": "Usar bandeja horizontal"
},
"audio": {
"enableSystemAudio": "Activar audio del sistema",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "Mettre en pause l'enregistrement",
"resumeRecording": "Reprendre l'enregistrement",
"openVideoFile": "Ouvrir un fichier vidéo",
"openProject": "Ouvrir un projet"
"openProject": "Ouvrir un projet",
"useVerticalTray": "Utiliser la barre verticale",
"useHorizontalTray": "Utiliser la barre horizontale"
},
"audio": {
"enableSystemAudio": "Activer l'audio système",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "Metti in pausa registrazione",
"resumeRecording": "Riprendi registrazione",
"openVideoFile": "Apri file video",
"openProject": "Apri progetto"
"openProject": "Apri progetto",
"useVerticalTray": "Usa barra verticale",
"useHorizontalTray": "Usa barra orizzontale"
},
"audio": {
"enableSystemAudio": "Abilita audio di sistema",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "録画を一時停止",
"resumeRecording": "録画を再開",
"openVideoFile": "動画ファイルを開く",
"openProject": "プロジェクトを開く"
"openProject": "プロジェクトを開く",
"useVerticalTray": "縦型トレイを使用",
"useHorizontalTray": "横型トレイを使用"
},
"audio": {
"enableSystemAudio": "システム音声を有効にする",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "녹화 일시정지",
"resumeRecording": "녹화 재개",
"openVideoFile": "비디오 파일 열기",
"openProject": "프로젝트 열기"
"openProject": "프로젝트 열기",
"useVerticalTray": "세로 트레이 사용",
"useHorizontalTray": "가로 트레이 사용"
},
"audio": {
"enableSystemAudio": "시스템 오디오 활성화",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "Приостановить запись",
"resumeRecording": "Возобновить запись",
"openVideoFile": "Открыть видеофайл",
"openProject": "Открыть проект"
"openProject": "Открыть проект",
"useVerticalTray": "Использовать вертикальную панель",
"useHorizontalTray": "Использовать горизонтальную панель"
},
"audio": {
"enableSystemAudio": "Включить системное аудио",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "Kaydı duraklat",
"resumeRecording": "Kayda devam et",
"openVideoFile": "Video dosyası aç",
"openProject": "Proje aç"
"openProject": "Proje aç",
"useVerticalTray": "Dikey araç çubuğunu kullan",
"useHorizontalTray": "Yatay araç çubuğunu kullan"
},
"audio": {
"enableSystemAudio": "Sistem sesini etkinleştir",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "Tạm dừng ghi hình",
"resumeRecording": "Tiếp tục ghi hình",
"openVideoFile": "Mở tệp video",
"openProject": "Mở dự án"
"openProject": "Mở dự án",
"useVerticalTray": "Dùng khay dọc",
"useHorizontalTray": "Dùng khay ngang"
},
"audio": {
"enableSystemAudio": "Bật âm thanh hệ thống",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "暂停录制",
"resumeRecording": "继续录制",
"openVideoFile": "打开视频文件",
"openProject": "打开项目"
"openProject": "打开项目",
"useVerticalTray": "使用竖向托盘",
"useHorizontalTray": "使用横向托盘"
},
"audio": {
"enableSystemAudio": "启用系统音频",
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "暫停錄製",
"resumeRecording": "繼續錄製",
"openVideoFile": "開啟影片檔案",
"openProject": "開啟專案"
"openProject": "開啟專案",
"useVerticalTray": "使用直向托盤",
"useHorizontalTray": "使用橫向托盤"
},
"audio": {
"enableSystemAudio": "啟用系統音訊",
+20 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parentDirectoryOf } from "./userPreferences";
import { beforeEach, describe, expect, it } from "vitest";
import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "./userPreferences";
describe("parentDirectoryOf", () => {
it("returns the directory for a POSIX path", () => {
@@ -24,3 +24,21 @@ describe("parentDirectoryOf", () => {
expect(parentDirectoryOf("")).toBeNull();
});
});
describe("user preferences", () => {
beforeEach(() => {
localStorage.clear();
});
it("persists the tray layout preference", () => {
saveUserPreferences({ trayLayout: "vertical" });
expect(loadUserPreferences().trayLayout).toBe("vertical");
});
it("falls back to the default tray layout for invalid stored values", () => {
localStorage.setItem("openscreen_user_preferences", JSON.stringify({ trayLayout: "diagonal" }));
expect(loadUserPreferences().trayLayout).toBe("horizontal");
});
});
+8
View File
@@ -29,6 +29,8 @@ export interface UserPreferences {
exportFormat: ExportFormat;
/** Folder used for the most recent successful export, if any */
exportFolder: string | null;
/** Recording HUD control layout */
trayLayout: "horizontal" | "vertical";
}
export const DEFAULT_PREFS: UserPreferences = {
@@ -37,8 +39,10 @@ export const DEFAULT_PREFS: UserPreferences = {
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
exportFolder: null,
trayLayout: "horizontal",
};
/** Parses stored preferences without throwing on malformed JSON. */
function safeJsonParse(text: string | null): Record<string, unknown> | null {
if (!text) return null;
try {
@@ -87,6 +91,10 @@ export function loadUserPreferences(): UserPreferences {
typeof raw.exportFolder === "string" && raw.exportFolder.length > 0
? raw.exportFolder
: DEFAULT_PREFS.exportFolder,
trayLayout:
raw.trayLayout === "horizontal" || raw.trayLayout === "vertical"
? raw.trayLayout
: DEFAULT_PREFS.trayLayout,
};
}