fix(electron): widen persistent secondary sidebars and fit video controls
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 profile’s 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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user