fix(electron): widen persistent secondary sidebars and fit video controls

This commit is contained in:
Palash Debnath
2026-09-15 15:14:22 +05:30
parent f137333abd
commit c524ea3235
6 changed files with 52 additions and 13 deletions
+1
View File
@@ -27,6 +27,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Workspace sidebars have a working right-edge resize handle, allow 40% more width, remember their size, and keep video controls inside the preview (#2129)
- Pressing Play while a video is loading starts playback when it is ready instead of reporting playback unavailable (#2129)
- Video previews show their thumbnail before playback, including the source video in Dub (#2129)
- Linux and Windows workspace headers consistently expand and collapse the sidebar, with the app logo at the top of the collapsed rail (#2129)
+2
View File
@@ -81,3 +81,5 @@ Every main workspace header exposes the same sidebar toggle, including pages tha
The shared video player renders Vidstack's poster before playback, including the Dub source thumbnail, and hides it once playback starts. Play requests made while the video is loading wait for the provider to become ready, including timeline preview requests.
On Linux Wayland systems where Chromium logs `eglCreateImage failed` / `OzoneImageBacking` and video or window contents flicker, launch Electron with `--disable-gpu-compositing`. For source development, run `bun run dev:software-compositing` from `electron/`. This opt-in uses software window compositing while leaving backend CUDA inference available; it does not disable acceleration for other installations. It requires a full Electron restart, not a renderer reload. A refused connection to port 3903 instead means the development proxy is stopped; restart the Electron development process to restore it.
Secondary workspace sidebars resize from their right edge up to 40% wider than the previous limits (515 / 616 / 750 px by size), while reserving space for the main workspace. Widths are saved per size in the app profiles local storage and restored on navigation and restart. Double-click the divider to reset the width; focus it and use arrow keys for keyboard resizing. Sidebar sections fill the resized width, and video controls adapt to the player width.
@@ -70,7 +70,7 @@ export const VideoPlayer = memo(function VideoPlayer({
onPause={onPause}
onSeeked={onSeeked}
onCanPlay={onCanPlay}
className="group relative overflow-hidden rounded-xl border border-white/10 bg-black text-white shadow-[0_16px_40px_-24px_rgb(0_0_0/85%)]"
className="group @container/player relative w-full min-w-0 overflow-hidden rounded-xl border border-white/10 bg-black text-white shadow-[0_16px_40px_-24px_rgb(0_0_0/85%)]"
>
<MediaProvider
loaders={videoLoaders}
@@ -143,7 +143,7 @@ function VideoControls({
}, [player, time]);
return (
<div
className={`absolute inset-x-2 bottom-2 z-10 space-y-2 rounded-xl border border-white/10 bg-black/55 px-2.5 pt-8 pb-2 text-white shadow-[0_12px_32px_rgb(0_0_0/38%)] backdrop-blur-xl transition-[opacity,transform] duration-200 group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100 ${paused || waiting ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'}`}
className={`absolute inset-x-2 bottom-2 z-10 space-y-2 rounded-xl border border-white/10 bg-black/55 px-2.5 py-2 text-white shadow-[0_12px_32px_rgb(0_0_0/38%)] backdrop-blur-xl transition-[opacity,transform] duration-200 group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100 ${paused || waiting ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'}`}
>
{(error || failed) && (
<p role="alert" className="text-xs text-destructive">
@@ -167,7 +167,7 @@ function VideoControls({
if (player.current) player.current.currentTime = Number(event.currentTarget.value);
}}
/>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-1 @min-[420px]/player:gap-2">
<Button
size="icon-sm"
variant="ghost"
@@ -236,7 +236,7 @@ function VideoControls({
max={1}
step="0.05"
value={muted ? 0 : volume}
className="hidden h-1 w-14 shrink-0 cursor-pointer appearance-none rounded-full bg-white/25 accent-primary [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white sm:block"
className="hidden h-1 w-14 shrink-0 cursor-pointer appearance-none rounded-full bg-white/25 accent-primary [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white @min-[420px]/player:block"
onInput={(event) => {
if (player.current) {
player.current.muted = false;
@@ -0,0 +1,28 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { FilmIcon } from 'lucide-react';
import { SecondarySidebar } from './workspace-sidebar';
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
afterEach(() => {
cleanup();
localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('allows a 40% wider spacious pane and restores the saved width on remount', () => {
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1400);
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
const pane = <SecondarySidebar title="Dub" icon={FilmIcon} size="spacious"><section>Preview</section></SecondarySidebar>;
const first = render(pane);
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemax', '750');
fireEvent.keyDown(separator, { key: 'ArrowRight' });
expect(separator).toHaveAttribute('aria-valuenow', '436');
expect(localStorage.getItem('voicestudio.secondary-sidebar.spacious')).toBe('436');
first.unmount();
render(pane);
expect(screen.getByRole('separator')).toHaveAttribute('aria-valuenow', '436');
});
@@ -6,9 +6,9 @@ import { usePaneResize } from '@/hooks/use-pane-resize';
import { cn } from '@/lib/utils';
const WIDTHS = {
default: { minimum: 248, initial: 280, maximum: 368, reserve: 480 },
wide: { minimum: 296, initial: 344, maximum: 440, reserve: 520 },
spacious: { minimum: 352, initial: 416, maximum: 536, reserve: 560 },
default: { minimum: 248, initial: 280, maximum: 515, reserve: 480 },
wide: { minimum: 296, initial: 344, maximum: 616, reserve: 520 },
spacious: { minimum: 352, initial: 416, maximum: 750, reserve: 560 },
} as const;
const VARIANT_STYLES = {
@@ -61,7 +61,7 @@ export function SecondarySidebar({
} as CSSProperties
}
className={cn(
'secondary-sidebar relative flex min-h-0 shrink-0 flex-col border-r border-border/55 bg-[color-mix(in_oklab,var(--muted)_13%,var(--background))] shadow-[inset_-1px_0_0_color-mix(in_oklab,var(--foreground)_2%,transparent)] [container-type:inline-size]',
'secondary-sidebar relative [--pane-resize-display:flex] @max-[40rem]:[--pane-resize-display:none] flex min-h-0 shrink-0 flex-col border-r border-border/55 bg-[color-mix(in_oklab,var(--muted)_13%,var(--background))] shadow-[inset_-1px_0_0_color-mix(in_oklab,var(--foreground)_2%,transparent)] [container-type:inline-size]',
collapsed
? 'w-11'
: 'w-[var(--secondary-sidebar-width)] @max-[40rem]:max-h-[40%] @max-[40rem]:w-full @max-[40rem]:border-r-0 @max-[40rem]:border-b',
@@ -71,7 +71,7 @@ export function SecondarySidebar({
<div
{...resize.separatorProps}
aria-label={title}
className="group/resize absolute inset-y-0 -right-1 z-20 flex w-2 cursor-col-resize touch-none items-center justify-center outline-none @max-[40rem]:hidden"
className="group/resize absolute inset-y-0 -right-1 z-20 [display:var(--pane-resize-display)] w-2 cursor-col-resize touch-none items-center justify-center outline-none"
>
<span className="h-10 w-px rounded-full bg-border/0 transition-[height,background-color,box-shadow] duration-150 group-hover/resize:h-16 group-hover/resize:bg-primary/45 group-hover/resize:shadow-[0_0_8px_var(--primary)] group-focus-visible/resize:h-16 group-focus-visible/resize:bg-primary" />
</div>
@@ -117,7 +117,7 @@ export function SecondarySidebar({
hidden={collapsed}
data-slot="secondary-sidebar-content"
className={cn(
'studio-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3.5 text-[13px] [scroll-padding-block:0.875rem] [scrollbar-gutter:stable] [&>*]:min-w-0 [&_button]:max-w-full [&_h2]:tracking-[-0.012em] [&_h3]:tracking-[-0.01em] [&_input]:max-w-full [&_label]:leading-5 [&_p]:leading-[1.55] [&_summary]:rounded-lg [&_summary]:outline-none [&_summary]:transition-[color,background-color] [&_summary]:duration-150 [&_summary:hover]:text-foreground [&_summary:focus-visible]:ring-2 [&_summary:focus-visible]:ring-ring/35 [&_textarea]:max-w-full',
'studio-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3.5 text-[13px] [scroll-padding-block:0.875rem] [scrollbar-gutter:stable] [&>*]:min-w-0 [&>section]:w-full [&>section]:shrink-0 [&_button]:max-w-full [&_h2]:tracking-[-0.012em] [&_h3]:tracking-[-0.01em] [&_input]:max-w-full [&_label]:leading-5 [&_p]:leading-[1.55] [&_summary]:rounded-lg [&_summary]:outline-none [&_summary]:transition-[color,background-color] [&_summary]:duration-150 [&_summary:hover]:text-foreground [&_summary:focus-visible]:ring-2 [&_summary:focus-visible]:ring-ring/35 [&_textarea]:max-w-full',
VARIANT_STYLES[variant],
collapsed && 'hidden',
className,
+11 -3
View File
@@ -1,6 +1,11 @@
import { chromium } from "playwright";
import assert from "node:assert/strict";
const browser = await chromium.launch({ channel: "msedge", headless: true });
const browser = await chromium.launch({
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH
? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH }
: { channel: "msedge" }),
headless: true,
});
const page = await browser.newPage();
const baseUrl = process.env.VOICESTUDIO_SMOKE_URL ?? "http://localhost:3912";
await page.addInitScript(() => {
@@ -24,6 +29,7 @@ try {
await page.waitForTimeout(100);
const sideCandidates = page.locator("[data-slot=secondary-sidebar]");
await sideCandidates.first().waitFor({ state: "attached" });
await sideCandidates.first().getByRole("separator").waitFor({ state: "visible" });
const compactMain = page.locator("[data-slot=compact-main-sidebar]");
if (width <= 1680) {
await compactMain.waitFor();
@@ -97,6 +103,8 @@ try {
);
});
const side = sideCandidates.first();
// Let the shell toggle and ResizeObserver settle before recording the width.
await page.waitForTimeout(200);
const expanded = await side.evaluate((element) => {
const bounds = element.getBoundingClientRect();
return { width: bounds.width, height: bounds.height };
@@ -122,8 +130,8 @@ try {
),
);
await toggle.click();
assert.equal(
await side.evaluate((element) => element.getBoundingClientRect().width),
await page.waitForFunction((expected) =>
document.querySelector("[data-slot=secondary-sidebar]")?.getBoundingClientRect().width === expected,
expanded.width,
);
assert.ok(