diff --git a/native/src/window_chrome.cpp b/native/src/window_chrome.cpp index 45ab208..1859c50 100644 --- a/native/src/window_chrome.cpp +++ b/native/src/window_chrome.cpp @@ -28,6 +28,89 @@ namespace { +// Undocumented but long-stable user32 API used for accent-policy acrylic. +// Unlike DWMWA_SYSTEMBACKDROP_TYPE acrylic, this blur stays active while the +// window is unfocused and renders on borderless transparent windows, which is +// exactly what an always-visible visualizer overlay needs. +struct AccentPolicy { + int32_t AccentState; + int32_t AccentFlags; + uint32_t GradientColor; // AABBGGRR + int32_t AnimationId; +}; + +struct WindowCompositionAttribData { + int32_t Attrib; + void* pvData; + SIZE_T cbData; +}; + +using SetWindowCompositionAttributeFn = BOOL(WINAPI*)(HWND, WindowCompositionAttribData*); + +constexpr int32_t kAccentDisabled = 0; +constexpr int32_t kAccentEnableAcrylicBlurBehind = 4; +constexpr int32_t kWcaAccentPolicy = 19; +// A barely-visible tint keeps some Windows builds from optimizing the blur +// away entirely when the requested tint alpha is zero. +constexpr uint32_t kNearTransparentTint = 0x01000000u; + +HWND ReadWindowHandle(const Napi::CallbackInfo& info, Napi::Env env) { + if (info.Length() < 1 || !info[0].IsBuffer()) { + Napi::TypeError::New(env, "Expected native window handle Buffer") + .ThrowAsJavaScriptException(); + return nullptr; + } + + Napi::Buffer handle = info[0].As>(); + if (handle.Length() < sizeof(HWND)) { + Napi::TypeError::New(env, "Window handle Buffer is too small") + .ThrowAsJavaScriptException(); + return nullptr; + } + + HWND hwnd = *reinterpret_cast(handle.Data()); + if (hwnd == nullptr || !IsWindow(hwnd)) { + return nullptr; + } + return hwnd; +} + +Napi::Value SetAcrylicBlurBehind(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + HWND hwnd = ReadWindowHandle(info, env); + if (env.IsExceptionPending() || hwnd == nullptr) { + return env.IsExceptionPending() ? env.Undefined() : Napi::Boolean::New(env, false); + } + + const bool enable = info.Length() > 1 && info[1].IsBoolean() + ? info[1].As().Value() + : true; + + HMODULE user32 = GetModuleHandleW(L"user32.dll"); + if (user32 == nullptr) { + return Napi::Boolean::New(env, false); + } + + auto setWindowCompositionAttribute = reinterpret_cast( + GetProcAddress(user32, "SetWindowCompositionAttribute")); + if (setWindowCompositionAttribute == nullptr) { + return Napi::Boolean::New(env, false); + } + + AccentPolicy policy = {}; + policy.AccentState = enable ? kAccentEnableAcrylicBlurBehind : kAccentDisabled; + policy.AccentFlags = 0; + policy.GradientColor = enable ? kNearTransparentTint : 0u; + policy.AnimationId = 0; + + WindowCompositionAttribData data = {}; + data.Attrib = kWcaAccentPolicy; + data.pvData = &policy; + data.cbData = sizeof(policy); + + return Napi::Boolean::New(env, setWindowCompositionAttribute(hwnd, &data) != FALSE); +} + Napi::Value ApplyFlatFrame(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (info.Length() < 1 || !info[0].IsBuffer()) { @@ -74,6 +157,7 @@ Napi::Value ApplyFlatFrame(const Napi::CallbackInfo& info) { void RegisterWindowChrome(Napi::Env env, Napi::Object exports) { Napi::Object chrome = Napi::Object::New(env); chrome.Set("applyFlatFrame", Napi::Function::New(env, ApplyFlatFrame)); + chrome.Set("setAcrylicBlurBehind", Napi::Function::New(env, SetAcrylicBlurBehind)); exports.Set("windowChrome", chrome); } @@ -90,6 +174,7 @@ Napi::Value ApplyFlatFrameNoop(const Napi::CallbackInfo& info) { void RegisterWindowChrome(Napi::Env env, Napi::Object exports) { Napi::Object chrome = Napi::Object::New(env); chrome.Set("applyFlatFrame", Napi::Function::New(env, ApplyFlatFrameNoop)); + chrome.Set("setAcrylicBlurBehind", Napi::Function::New(env, ApplyFlatFrameNoop)); exports.Set("windowChrome", chrome); } diff --git a/src/main/index.ts b/src/main/index.ts index 60cfb2c..1f2401c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -40,7 +40,9 @@ import { MacSpotifyProvider } from './services/macSpotifyProvider' import { SecretVault } from './services/secretVault' import { checkForUpdates, resolveSafeReleaseUrl } from './services/updates' import { FileBackedThemeLibrary } from './themeLibrary' +import { normalizeWindowBackgroundState } from '../shared/windowState' import { FileBackedWindowStateStore } from './windowStateStore' +import type { WindowBackgroundSnapshot, WindowBackgroundState } from '../types/windowState' import type { NativeWindowsMediaAPI } from '../types/nativeWindowsMedia' import type { NativeWindowChromeAPI } from '../types/nativeWindowChrome' @@ -59,6 +61,7 @@ let allowMainWindowClose = false let mainWindowClosePending = false let suppressMainWindowSyncUntil = 0 let mainWindowLogicalBounds: WindowBounds | null = null +let windowRecreationPending = false const scopePopoutWindows = new Map() const scopePopoutCloseAllowed = new Set() @@ -107,6 +110,7 @@ const runtimeWindowCapabilities = resolveWindowCapabilities({ platform: process.platform, argv: process.argv, env: process.env, + osVersion: process.getSystemVersion(), }) interface ResolvedBuildMetadata { @@ -382,6 +386,27 @@ function applyFlatFramelessChrome(window: BrowserWindow): void { } } +// Accent-policy acrylic for blurred windows. The DWM system backdrop +// (backgroundMaterial: 'acrylic') is unusable here: it greys out whenever the +// window loses focus and fights the flat frameless chrome. The accent blur +// stays active unfocused and renders on borderless transparent windows. +function applyAcrylicBlurBehind(window: BrowserWindow): void { + if (process.platform !== 'win32') { + return + } + + const api = getNativeWindowChromeApi() + if (!api || typeof api.setAcrylicBlurBehind !== 'function') { + return + } + + try { + api.setAcrylicBlurBehind(window.getNativeWindowHandle(), true) + } catch { + // Blur is purely cosmetic; the window still works as a clear window. + } +} + function getNowPlayingManager(): NowPlayingManager { if (!nowPlayingManager) { nowPlayingManager = new NowPlayingManager({ @@ -795,20 +820,49 @@ function isCursorInsideWindow(window: BrowserWindow): boolean { && cursor.y < bounds.y + bounds.height } -function getSnapCapableFramelessWindowOptions(): Pick< - BrowserWindowConstructorOptions, - 'frame' | 'transparent' | 'backgroundColor' | 'roundedCorners' | 'hasShadow' | 'thickFrame' | 'backgroundMaterial' | 'resizable' | 'maximizable' | 'fullscreenable' | 'minimizable' | 'skipTaskbar' -> { +const SOLID_WINDOW_BACKGROUND: WindowBackgroundState = { mode: 'solid', transparency: 0 } + +function getEffectiveWindowBackground(): WindowBackgroundState { + const stored = getWindowStateStore().getWindowBackground() + if (stored.mode === 'blurred' && !runtimeWindowCapabilities.supportsBlurredBackground) { + return { ...stored, mode: 'solid' } + } + return stored +} + +function getWindowBackgroundSnapshot(): WindowBackgroundSnapshot { + return { + stored: getWindowStateStore().getWindowBackground(), + effective: getEffectiveWindowBackground(), + } +} + +// Solid windows keep WS_THICKFRAME, so native Aero Snap and edge resize stay +// intact. On Windows, blurred and clear windows must be created `transparent` +// (blurred composites accent-policy acrylic behind the alpha pixels), which is +// mutually exclusive with the thick frame — those windows fall back to the JS +// move/resize controllers. On macOS, blurred uses vibrancy and keeps the +// native frame semantics. +function getFramelessWindowOptions( + background: WindowBackgroundState = SOLID_WINDOW_BACKGROUND, +): BrowserWindowConstructorOptions { + const transparentOnWindows = process.platform === 'win32' && background.mode !== 'solid' return { frame: false, - transparent: false, - backgroundColor: '#000000', + transparent: background.mode === 'clear' || transparentOnWindows, + backgroundColor: background.mode === 'solid' ? '#000000' : '#00000000', roundedCorners: false, hasShadow: false, ...(process.platform === 'win32' ? { - thickFrame: true, - backgroundMaterial: 'none', + thickFrame: background.mode === 'solid', + backgroundMaterial: 'none' as const, + } + : {}), + ...(process.platform === 'darwin' && background.mode === 'blurred' + ? { + vibrancy: 'under-window' as const, + visualEffectState: 'active' as const, } : {}), resizable: true, @@ -819,6 +873,65 @@ function getSnapCapableFramelessWindowOptions(): Pick< } } +function getBackgroundCapableWindows(): BrowserWindow[] { + const windows: BrowserWindow[] = [] + if (mainWindow && !mainWindow.isDestroyed()) { + windows.push(mainWindow) + } + for (const window of scopePopoutWindows.values()) { + if (!window.isDestroyed()) { + windows.push(window) + } + } + return windows +} + +function broadcastWindowBackgroundChanged(snapshot: WindowBackgroundSnapshot): void { + for (const window of getBackgroundCapableWindows()) { + if (!window.webContents.isDestroyed()) { + window.webContents.send('window:background-changed', snapshot) + } + } +} + +function getWindowBackgroundQuery(background: WindowBackgroundState): Record { + return { + bg: background.mode, + bgt: String(background.transparency), + } +} + +// Every mode switch recreates the main window: `transparent` (clear) is a +// creation-time flag, and the flat-chrome DWM tweaks applied to solid windows +// (DWMWA_NCRENDERING_POLICY disabled) are sticky on the HWND and fight the +// acrylic backdrop — DWM flickers between composited states, worst at screen +// edges. A fresh window gets exactly the right chrome for its mode. Popouts +// are destroyed alongside it and re-opened by the fresh renderer from +// persisted profile state. +function recreateWindowsForBackgroundChange(): void { + if (!mainWindow || mainWindow.isDestroyed()) { + createMainWindow() + return + } + + const restoreBounds = toLogicalBounds(mainWindow) + const wasMaximized = mainWindow.isMaximized() + + windowRecreationPending = true + allowMainWindowClose = true + mainWindow.once('closed', () => { + try { + createMainWindow(restoreBounds) + if (wasMaximized) { + mainWindow?.maximize() + } + } finally { + windowRecreationPending = false + } + }) + mainWindow.close() +} + function normalizeProfileMenuRequest(raw: unknown): ProfileMenuRequest | null { if (typeof raw !== 'object' || raw === null) return null @@ -1022,10 +1135,16 @@ async function showCustomDialog(options: DialogOptions): Promise { }) } -function createMainWindow(): void { +function createMainWindow(restoreBounds?: WindowBounds): void { + const background = getEffectiveWindowBackground() + const initialBounds = restoreBounds + ? clampRestoredWindowBounds(restoreBounds, getDisplayWorkAreas(), RESTORED_WINDOW_VISIBLE_MARGIN) + : null + mainWindow = new BrowserWindow({ ...WINDOW_DEFAULTS, - ...getSnapCapableFramelessWindowOptions(), + ...(initialBounds ?? {}), + ...getFramelessWindowOptions(background), alwaysOnTop: getWindowStateStore().getMainAlwaysOnTop(), autoHideMenuBar: true, resizable: true, @@ -1041,7 +1160,11 @@ function createMainWindow(): void { backgroundThrottling: false, }, }) - applyFlatFramelessChrome(mainWindow) + if (background.mode === 'solid') { + applyFlatFramelessChrome(mainWindow) + } else if (background.mode === 'blurred') { + applyAcrylicBlurBehind(mainWindow) + } syncMainWindowLogicalBounds(mainWindow) mainWindow.on('close', (event) => { @@ -1106,7 +1229,7 @@ function createMainWindow(): void { raiseMainWindowAboveNormalPopouts() }) - loadRendererTarget(mainWindow, { window: 'main' }) + loadRendererTarget(mainWindow, { window: 'main', ...getWindowBackgroundQuery(background) }) } function sendScopePopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void { @@ -1225,12 +1348,13 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro : normalizedBounds suppressNextPopoutBoundsEvents.add(kind) + const background = getEffectiveWindowBackground() const options: BrowserWindowConstructorOptions = { width: bounds.width, height: bounds.height, minWidth: POPOUT_DEFAULTS.minWidth, minHeight: POPOUT_DEFAULTS.minHeight, - ...getSnapCapableFramelessWindowOptions(), + ...getFramelessWindowOptions(background), autoHideMenuBar: true, title: `Prism ${SCOPE_LABELS[kind]}`, alwaysOnTop: getWindowStateStore().getPopoutAlwaysOnTop(kind), @@ -1251,7 +1375,11 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro } const popoutWindow = new BrowserWindow(options) - applyFlatFramelessChrome(popoutWindow) + if (background.mode === 'solid') { + applyFlatFramelessChrome(popoutWindow) + } else if (background.mode === 'blurred') { + applyAcrylicBlurBehind(popoutWindow) + } setSettingsHeightForWindow(popoutWindow, 0) scopePopoutWindows.set(kind, popoutWindow) @@ -1290,7 +1418,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro popoutWindow.on('move', () => emitPopoutBoundsChanged(kind, popoutWindow)) popoutWindow.on('resize', () => emitPopoutBoundsChanged(kind, popoutWindow)) - loadRendererTarget(popoutWindow, { window: 'scope-popout', scope: kind }) + loadRendererTarget(popoutWindow, { window: 'scope-popout', scope: kind, ...getWindowBackgroundQuery(background) }) return popoutWindow } @@ -1399,7 +1527,8 @@ function createNowPlayingConfigWindow(): BrowserWindow { height: bounds.height, minWidth: NOW_PLAYING_CONFIG_DEFAULTS.minWidth, minHeight: NOW_PLAYING_CONFIG_DEFAULTS.minHeight, - ...getSnapCapableFramelessWindowOptions(), + // The config window is a form UI; it always keeps a solid background. + ...getFramelessWindowOptions(), autoHideMenuBar: true, title: 'Prism Now Playing', show: false, @@ -1589,6 +1718,26 @@ function setupIPC(): void { return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? false }) + ipcMain.handle('window:get-background', () => { + return getWindowBackgroundSnapshot() + }) + + ipcMain.handle('window:set-background', async (_event, raw: unknown) => { + const next = normalizeWindowBackgroundState(raw) + const previousEffective = getEffectiveWindowBackground() + await getWindowStateStore().setWindowBackground(next) + const nextEffective = getEffectiveWindowBackground() + + if (previousEffective.mode !== nextEffective.mode) { + recreateWindowsForBackgroundChange() + } else { + // Transparency-only changes are pure CSS in the renderers. + broadcastWindowBackgroundChanged(getWindowBackgroundSnapshot()) + } + + return getWindowBackgroundSnapshot() + }) + ipcMain.handle('window:is-cursor-inside', (event) => { const targetWindow = getWindowFromSender(event.sender) return targetWindow ? isCursorInsideWindow(targetWindow) : false @@ -1987,6 +2136,10 @@ if (!hasSingleInstanceLock) { } app.on('window-all-closed', () => { + if (windowRecreationPending) { + return + } + void nowPlayingManager?.dispose() app.quit() }) diff --git a/src/main/windowStateStore.ts b/src/main/windowStateStore.ts index 39598f5..e860c13 100644 --- a/src/main/windowStateStore.ts +++ b/src/main/windowStateStore.ts @@ -3,7 +3,7 @@ import { dirname } from 'node:path' import { createEmptyWindowLocalState, normalizeWindowLocalState } from '../shared/windowState' import type { WindowBounds } from '../types/popout' import type { ScopeKind } from '../types/scope' -import type { PrismWindowLocalStateV1 } from '../types/windowState' +import type { PrismWindowLocalStateV1, WindowBackgroundState } from '../types/windowState' export class FileBackedWindowStateStore { private state: PrismWindowLocalStateV1 = createEmptyWindowLocalState() @@ -26,6 +26,10 @@ export class FileBackedWindowStateStore { return this.state.popoutAlwaysOnTop[kind] === true } + getWindowBackground(): WindowBackgroundState { + return { ...this.state.windowBackground } + } + getNowPlayingConfigWindowBounds(): WindowBounds | undefined { return this.state.nowPlayingConfigWindowBounds ? { ...this.state.nowPlayingConfigWindowBounds } @@ -59,6 +63,16 @@ export class FileBackedWindowStateStore { await this.persistState() } + async setWindowBackground(background: WindowBackgroundState): Promise { + await this.ensureInitialized() + this.state = normalizeWindowLocalState({ + ...this.state, + windowBackground: background, + popoutAlwaysOnTop: { ...this.state.popoutAlwaysOnTop }, + }) + await this.persistState() + } + async setNowPlayingConfigWindowBounds(bounds?: WindowBounds): Promise { await this.ensureInitialized() this.state = normalizeWindowLocalState({ diff --git a/src/preload/index.ts b/src/preload/index.ts index db87368..363711e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -32,6 +32,7 @@ import type { DialogOptions, DialogResult } from '../types/dialog' import type { UpdateCheckResult } from '../types/updates' import type { WindowCapabilities } from '../types/windowCapabilities' import type { ResizeDirection } from '../types/windowResize' +import type { WindowBackgroundSnapshot, WindowBackgroundState } from '../types/windowState' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' import { resolveWindowCapabilities } from '../shared/windowCapabilities' import { getCaptureBackendSupport } from './captureSupport' @@ -41,6 +42,7 @@ const windowCapabilities: WindowCapabilities = resolveWindowCapabilities({ platform: process.platform, argv: process.argv, env: process.env, + osVersion: process.getSystemVersion(), }) // Expose Electron API to renderer @@ -59,6 +61,8 @@ contextBridge.exposeInMainWorld('electronAPI', { repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position), toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'), isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'), + getWindowBackground: () => ipcRenderer.invoke('window:get-background') as Promise, + setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise, isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise, getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport, getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise, @@ -120,6 +124,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('window:always-on-top-changed', handler) return () => ipcRenderer.removeListener('window:always-on-top-changed', handler) }, + onWindowBackgroundChanged: (callback: (snapshot: WindowBackgroundSnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, snapshot: WindowBackgroundSnapshot): void => callback(snapshot) + ipcRenderer.on('window:background-changed', handler) + return () => ipcRenderer.removeListener('window:background-changed', handler) + }, onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => { const handler = (_event: Electron.IpcRendererEvent, bounds: WindowBounds): void => callback(bounds) ipcRenderer.on('window:bounds-changed', handler) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c78cec6..eecafd6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5,12 +5,14 @@ import SettingsPanel from './components/SettingsPanel' import BottomBar from './components/BottomBar' import ScopePopoutBridge from './components/ScopePopoutBridge' import AppBanner from './components/AppBanner' +import WindowResizeOverlay from './components/WindowResizeOverlay' import { resolveMainWindowSettingsHeight } from './mainWindowSettings' import { useSettingsStore } from './stores/settingsStore' import { startAudioDeviceWatcher, useAudioStore } from './stores/audioStore' import { useNowPlayingStore } from './stores/nowPlayingStore' import { useThemeStore } from './stores/themeStore' import { useUiStore } from './stores/uiStore' +import { useWindowBackgroundStore } from './stores/windowBackgroundStore' import { useUpdateStore } from './stores/updateStore' import { getRendererWindowCapabilities } from './windowCapabilities' @@ -38,6 +40,8 @@ export default function App(): JSX.Element { const toggleSettings = useUiStore((s) => s.toggleSettings) const setSettingsOpen = useUiStore((s) => s.setSettingsOpen) const showBanner = useUiStore((s) => s.showBanner) + const initializeWindowBackground = useWindowBackgroundStore((s) => s.initialize) + const windowBackgroundMode = useWindowBackgroundStore((s) => s.effective.mode) const useNativeDragRegions = getRendererWindowCapabilities().useNativeDragRegions const isNowPlayingVisible = !hiddenScopes.has('nowPlaying') @@ -59,6 +63,10 @@ export default function App(): JSX.Element { void useUpdateStore.getState().checkForUpdates() }, []) + useEffect(() => { + void initializeWindowBackground() + }, [initializeWindowBackground]) + useEffect(() => { let isDisposed = false @@ -305,6 +313,7 @@ export default function App(): JSX.Element { + {windowBackgroundMode !== 'solid' && } ) } diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index f01d6ed..9ed7043 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -5,10 +5,13 @@ import { usePerformanceStore } from '../stores/performanceStore' import { useSettingsStore } from '../stores/settingsStore' import { useThemeStore } from '../stores/themeStore' import { useUiStore } from '../stores/uiStore' +import { useWindowBackgroundStore } from '../stores/windowBackgroundStore' +import { getRendererWindowCapabilities } from '../windowCapabilities' import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll' import type { ScopeKind } from '../../types/scope' import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance' import { SCOPE_KINDS } from '../../types/scope' +import type { WindowBackgroundMode, WindowBackgroundState } from '../../types/windowState' import ThemedSelect from './ThemedSelect' const SCOPE_LABELS: Record = { @@ -38,6 +41,22 @@ const FRAME_TARGET_LABELS: Record = { const DEFAULT_INPUT_DEVICE_ID = '__default_input__' +const WINDOW_BACKGROUND_MODES: readonly WindowBackgroundMode[] = ['solid', 'blurred', 'clear'] + +const WINDOW_BACKGROUND_MODE_LABELS: Record = { + solid: 'Solid', + blurred: 'Blurred', + clear: 'Clear', +} + +const WINDOW_BACKGROUND_MODE_TITLES: Record = { + solid: 'Opaque themed background', + blurred: 'Desktop shows through, blurred', + clear: 'Desktop shows through, crisp', +} + +const WINDOW_BACKGROUND_SET_THROTTLE_MS = 60 + function getErrorMessage(error: unknown, fallback: string): string { return error instanceof Error && error.message ? error.message @@ -102,6 +121,32 @@ export function resolveThemeCreditDetails(theme: ThemeCreditSource): { export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element { const rootRef = useRef(null) const [isRefreshingThemes, setIsRefreshingThemes] = useState(false) + const windowBackground = useWindowBackgroundStore((s) => s.stored) + const previewWindowBackground = useWindowBackgroundStore((s) => s.previewBackground) + const setWindowBackground = useWindowBackgroundStore((s) => s.setBackground) + const supportsBlurredBackground = getRendererWindowCapabilities().supportsBlurredBackground + const pendingWindowBackgroundRef = useRef(null) + const windowBackgroundFlushTimerRef = useRef | null>(null) + + const queueWindowBackgroundSave = (next: WindowBackgroundState): void => { + previewWindowBackground(next) + pendingWindowBackgroundRef.current = next + if (windowBackgroundFlushTimerRef.current) return + + windowBackgroundFlushTimerRef.current = setTimeout(() => { + windowBackgroundFlushTimerRef.current = null + const pending = pendingWindowBackgroundRef.current + pendingWindowBackgroundRef.current = null + if (pending) { + void setWindowBackground(pending) + } + }, WINDOW_BACKGROUND_SET_THROTTLE_MS) + } + + const handleWindowBackgroundMode = (mode: WindowBackgroundMode): void => { + if (mode === windowBackground.mode) return + void setWindowBackground({ ...windowBackground, mode }) + } const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) const scopeOrder = useSettingsStore((s) => s.scopeOrder) @@ -441,6 +486,62 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
+
+
+
Window
+ {windowBackground.mode !== 'solid' ? ( + + Window snapping is disabled in this mode + + ) : null} +
+
+
+
+ {WINDOW_BACKGROUND_MODES.map((mode) => { + const unsupported = mode === 'blurred' && !supportsBlurredBackground + return ( + + ) + })} +
+ + {windowBackground.transparency}% + + { + queueWindowBackgroundSave({ + ...windowBackground, + transparency: Number(event.target.value), + }) + }} + title="How much of the desktop shows through" + /> +
+
+
+ +
+
Now Playing
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 2dc358c..a59fdad 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -15,6 +15,7 @@ import type { import type { SpectrumPeakInfo } from '../../types/spectrum' import { useSettingsStore } from '../stores/settingsStore' import { useThemeStore } from '../stores/themeStore' +import { useWindowBackgroundAlpha } from '../stores/windowBackgroundStore' import AstraScopeModule from './AstraScopeModule' import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer' import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope' @@ -315,6 +316,27 @@ export function scopeSettingsToOptions( } } +// In blurred/clear window modes the see-through tint lives in CSS (a stable +// compositor layer on .scope-strip); the canvas paints no background at all so +// per-frame redraws never race the OS backdrop. Traces stay fully opaque. +export function applyWindowBackgroundAlphaToOptions( + options: Record, + windowBgAlpha: number, +): Record { + if ( + windowBgAlpha >= 1 + || typeof options.backgroundColor !== 'string' + || options.backgroundColor === 'transparent' + ) { + return options + } + + return { + ...options, + backgroundColor: 'transparent', + } +} + function createVisualizer( scopeKind: ScopeKind, canvas: HTMLCanvasElement, @@ -324,8 +346,15 @@ function createVisualizer( dataSource?: ScopeModuleProps['dataSource'], onSpectrumPeakInfo?: (peakInfo: SpectrumPeakInfo | null) => void, captureSpectrumPeakInfo = false, + windowBgAlpha = 1, ): Visualizer | null { - const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, theme), frameScheduler } + const opts = { + ...applyWindowBackgroundAlphaToOptions( + scopeSettingsToOptions(scopeKind, mySettings, theme), + windowBgAlpha, + ), + frameScheduler, + } switch (scopeKind) { case 'spectrum': return new SpectrumAnalyzer(canvas, { @@ -392,6 +421,7 @@ export default function ScopeModule({ const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind]) const activeTheme = useThemeStore((s) => s.activeTheme) + const windowBgAlpha = useWindowBackgroundAlpha() const mySettings = settings ?? storeSettings const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind) const spectrumPeakMode = scopeKind === 'spectrum' @@ -469,6 +499,7 @@ export default function ScopeModule({ dataSource, handleSpectrumPeakInfo, captureSpectrumPeakInfo, + windowBgAlpha, ) if (!viz) return @@ -485,12 +516,15 @@ export default function ScopeModule({ initializedRef.current = false setSpectrumPeakInfo(null) } - }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, myTheme, mySettings, scopeKind]) + }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, myTheme, mySettings, scopeKind, windowBgAlpha]) useEffect(() => { if (!visualizerRef.current || !initializedRef.current) return const opts = { - ...scopeSettingsToOptions(scopeKind, mySettings, myTheme), + ...applyWindowBackgroundAlphaToOptions( + scopeSettingsToOptions(scopeKind, mySettings, myTheme), + windowBgAlpha, + ), frameScheduler, ...(scopeKind === 'spectrum' ? { @@ -501,7 +535,7 @@ export default function ScopeModule({ ...(dataSource ? { dataSource } : {}), } visualizerRef.current.setOptions(opts) - }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, mySettings, myTheme, scopeKind]) + }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, mySettings, myTheme, scopeKind, windowBgAlpha]) useEffect(() => { const container = containerRef.current diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 99bbdd7..4df7d6d 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -35,6 +35,7 @@ import type { DialogOptions, DialogResult } from '../types/dialog' import type { UpdateCheckResult } from '../types/updates' import type { WindowCapabilities } from '../types/windowCapabilities' import type { ResizeDirection } from '../types/windowResize' +import type { WindowBackgroundSnapshot, WindowBackgroundState } from '../types/windowState' declare global { interface Window { @@ -55,6 +56,8 @@ declare global { repositionWindow: (position: 'top' | 'bottom') => void toggleAlwaysOnTop: () => void isAlwaysOnTop: () => Promise + getWindowBackground: () => Promise + setWindowBackground: (state: WindowBackgroundState) => Promise isCursorInsideWindow: () => Promise getCaptureBackendSupport: () => Promise getNowPlayingState: () => Promise @@ -102,6 +105,7 @@ declare global { requestScopePopIn: (kind: ScopeKind) => void sendScopePopoutSettingsUpdate: (kind: ScopeKind, partial: unknown) => void onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void + onWindowBackgroundChanged: (callback: (snapshot: WindowBackgroundSnapshot) => void) => () => void onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => () => void onNowPlayingStateChanged: (callback: (state: NowPlayingState) => void) => () => void onMainCloseRequested: (callback: () => void) => () => void diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index b5b7471..d210b30 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -10,6 +10,7 @@ import '@fontsource/inter/500.css' import '@fontsource/inter/600.css' import '@fontsource/jetbrains-mono/400.css' import { SCOPE_KINDS, type ScopeKind } from '../types/scope' +import { bootstrapWindowBackgroundFromQuery } from './windowBackground' function isScopeKind(value: string | null): value is ScopeKind { return value !== null && SCOPE_KINDS.includes(value as ScopeKind) @@ -20,6 +21,10 @@ const windowMode = params.get('mode') const windowRole = params.get('window') const scopeKind = params.get('scope') +if (windowMode !== 'dialog' && windowRole !== 'now-playing-config') { + bootstrapWindowBackgroundFromQuery() +} + let root: React.ReactElement if (windowMode === 'dialog') { root = diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx index de959c1..f63fb72 100644 --- a/src/renderer/popouts/ScopePopoutWindow.tsx +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -5,8 +5,10 @@ import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings import { applyResolvedThemeToDocument, createDefaultTheme, resolveTheme } from '../../shared/themeState' import ScopeModule from '../components/ScopeModule' import ScopeSettingsSection from '../components/ScopeSettingsSection' +import WindowResizeOverlay from '../components/WindowResizeOverlay' import { usePerformanceStore } from '../stores/performanceStore' import { useUiStore } from '../stores/uiStore' +import { useWindowBackgroundStore } from '../stores/windowBackgroundStore' import { getRendererWindowCapabilities } from '../windowCapabilities' import { ScopePopoutDataSource } from './ScopePopoutDataSource' import { FrameScheduler } from '../visualizers/frameScheduler' @@ -68,12 +70,19 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind]) const useWindowManagerDragRegions = getRendererWindowCapabilities().useNativeDragRegions + const initializeWindowBackground = useWindowBackgroundStore((s) => s.initialize) + const windowBackgroundMode = useWindowBackgroundStore((s) => s.effective.mode) + useEffect(() => { void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop) const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop) return unsubscribe }, []) + useEffect(() => { + void initializeWindowBackground() + }, [initializeWindowBackground]) + useEffect(() => { frameScheduler.setFrameTarget(frameTarget) }, [frameScheduler, frameTarget]) @@ -304,6 +313,8 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
)} + + {windowBackgroundMode !== 'solid' && }
) } diff --git a/src/renderer/stores/windowBackgroundStore.ts b/src/renderer/stores/windowBackgroundStore.ts new file mode 100644 index 0000000..d6ca2c4 --- /dev/null +++ b/src/renderer/stores/windowBackgroundStore.ts @@ -0,0 +1,82 @@ +import { create } from 'zustand' +import { createDefaultWindowBackgroundState } from '../../shared/windowState' +import type { WindowBackgroundState } from '../../types/windowState' +import { + applyWindowBackgroundToDocument, + readWindowBackgroundFromQuery, + windowBackgroundAlpha, +} from '../windowBackground' + +interface WindowBackgroundStoreState { + /** The user's stored choice (shown in the settings UI). */ + stored: WindowBackgroundState + /** What the window is actually compositing with after capability downgrades. */ + effective: WindowBackgroundState + initialize: () => Promise + /** Apply locally without persisting — used while dragging the slider. */ + previewBackground: (state: WindowBackgroundState) => void + setBackground: (state: WindowBackgroundState) => Promise +} + +function canUseElectronAPI(): boolean { + return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined' +} + +function initialEffectiveState(): WindowBackgroundState { + if (typeof window !== 'undefined') { + const fromQuery = readWindowBackgroundFromQuery(window.location.search) + if (fromQuery) return fromQuery + } + return createDefaultWindowBackgroundState() +} + +let subscribed = false + +export const useWindowBackgroundStore = create((set, get) => ({ + stored: initialEffectiveState(), + effective: initialEffectiveState(), + + initialize: async () => { + if (!canUseElectronAPI()) return + + if (!subscribed) { + subscribed = true + window.electronAPI.onWindowBackgroundChanged((snapshot) => { + applyWindowBackgroundToDocument(snapshot.effective) + set({ stored: snapshot.stored, effective: snapshot.effective }) + }) + } + + const snapshot = await window.electronAPI.getWindowBackground() + applyWindowBackgroundToDocument(snapshot.effective) + set({ stored: snapshot.stored, effective: snapshot.effective }) + }, + + previewBackground: (next: WindowBackgroundState) => { + // Only preview when the compositing mode is unchanged — mode switches are + // driven by the main process (runtime material swap or window recreation). + if (next.mode === get().effective.mode) { + applyWindowBackgroundToDocument(next) + } + set({ stored: next }) + }, + + setBackground: async (next: WindowBackgroundState) => { + if (!canUseElectronAPI()) return + + get().previewBackground(next) + + try { + const snapshot = await window.electronAPI.setWindowBackground(next) + applyWindowBackgroundToDocument(snapshot.effective) + set({ stored: snapshot.stored, effective: snapshot.effective }) + } catch { + // Entering/leaving clear mode recreates this window, which can reject + // the invoke mid-flight — the fresh window reads the new state itself. + } + }, +})) + +export function useWindowBackgroundAlpha(): number { + return useWindowBackgroundStore((state) => windowBackgroundAlpha(state.effective)) +} diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index ed860b5..0236e3f 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -76,6 +76,40 @@ body, -webkit-font-smoothing: antialiased; } +/* Window background customization: in blurred/clear modes the OS desktop shows + through wherever the app paints with alpha. --window-bg-alpha (0..1) controls + how opaque the themed background stays; visualizer content remains crisp. + The tint lives on stable DOM layers (scope strips, panels) — never on the + constantly-repainting canvases — so it cannot shimmer against the backdrop. */ +html[data-window-bg='blurred'], +html[data-window-bg='clear'], +html[data-window-bg='blurred'] body, +html[data-window-bg='clear'] body, +html[data-window-bg='blurred'] #root, +html[data-window-bg='clear'] #root { + background: transparent; +} + +html[data-window-bg='blurred'] .scope-strip, +html[data-window-bg='clear'] .scope-strip, +html[data-window-bg='blurred'] .scope-popout, +html[data-window-bg='clear'] .scope-popout { + background-color: color-mix( + in srgb, + var(--scope-bg) calc(var(--window-bg-alpha, 1) * 100%), + transparent + ); +} + +html[data-window-bg='blurred'] .prism-settings-region, +html[data-window-bg='clear'] .prism-settings-region { + background: color-mix( + in srgb, + var(--bg-primary) calc(var(--window-bg-alpha, 1) * 100%), + transparent + ); +} + button, input, select { @@ -1804,6 +1838,10 @@ button.toolbar__version:hover { min-width: 420px; } +.bottom-bar__section--window { + min-width: 380px; +} + .bottom-bar__section--source { min-width: 360px; } @@ -1889,6 +1927,18 @@ button.toolbar__version:hover { margin: 0 4px; } +.bottom-bar__window-note { + min-width: 0; + margin-left: auto; + color: var(--text-tertiary); + font-size: 10px; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; +} + .bottom-bar__theme-credit--link { color: var(--text-secondary); text-decoration: none; diff --git a/src/renderer/windowBackground.ts b/src/renderer/windowBackground.ts new file mode 100644 index 0000000..25fdb89 --- /dev/null +++ b/src/renderer/windowBackground.ts @@ -0,0 +1,38 @@ +import { normalizeWindowBackgroundState } from '../shared/windowState' +import type { WindowBackgroundState } from '../types/windowState' + +export function windowBackgroundAlpha(state: WindowBackgroundState): number { + if (state.mode === 'solid') return 1 + return 1 - state.transparency / 100 +} + +export function applyWindowBackgroundToDocument(state: WindowBackgroundState): void { + if (typeof document === 'undefined') return + + document.documentElement.dataset.windowBg = state.mode + document.documentElement.style.setProperty( + '--window-bg-alpha', + String(windowBackgroundAlpha(state)), + ) +} + +// The main process appends the effective background to the renderer URL so the +// very first paint matches the window's compositing mode (no flash of opaque +// black on a transparent window or vice versa). +export function readWindowBackgroundFromQuery(search: string): WindowBackgroundState | null { + const params = new URLSearchParams(search) + const mode = params.get('bg') + if (!mode) return null + + return normalizeWindowBackgroundState({ + mode, + transparency: Number(params.get('bgt')), + }) +} + +export function bootstrapWindowBackgroundFromQuery(): void { + const state = readWindowBackgroundFromQuery(window.location.search) + if (state) { + applyWindowBackgroundToDocument(state) + } +} diff --git a/src/shared/windowCapabilities.ts b/src/shared/windowCapabilities.ts index e778458..c2cfc95 100644 --- a/src/shared/windowCapabilities.ts +++ b/src/shared/windowCapabilities.ts @@ -4,6 +4,7 @@ interface WindowCapabilityResolutionOptions { platform: string argv?: readonly string[] env?: Record + osVersion?: string } export const DEFAULT_WINDOW_CAPABILITIES: WindowCapabilities = { @@ -11,6 +12,21 @@ export const DEFAULT_WINDOW_CAPABILITIES: WindowCapabilities = { useNativeDragRegions: false, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, + supportsBlurredBackground: false, +} + +// Blurred mode uses accent-policy acrylic (SetWindowCompositionAttribute), +// which technically exists since Windows 10 1803, but drag/repaint performance +// is only dependable on Windows 11 22H2 (build 22621) — gate it there. +const WINDOWS_ACRYLIC_MIN_BUILD = 22621 + +function windowsBuildSupportsAcrylic(osVersion: string | undefined): boolean { + if (!osVersion) { + return false + } + + const build = Number.parseInt(osVersion.split('.')[2] ?? '', 10) + return Number.isFinite(build) && build >= WINDOWS_ACRYLIC_MIN_BUILD } function readSwitchValue(argv: readonly string[], switchName: string): string | null { @@ -80,6 +96,8 @@ export function resolveWindowCapabilities(options: WindowCapabilityResolutionOpt return { ...DEFAULT_WINDOW_CAPABILITIES, useNativeDragRegions: true, + supportsBlurredBackground: options.platform === 'darwin' + || windowsBuildSupportsAcrylic(options.osVersion), } } @@ -95,5 +113,6 @@ export function resolveWindowCapabilities(options: WindowCapabilityResolutionOpt useNativeDragRegions: isNativeWayland, supportsProgrammaticReposition: !isNativeWayland, supportsGeometryPersistence: !isNativeWayland, + supportsBlurredBackground: false, } } diff --git a/src/shared/windowState.ts b/src/shared/windowState.ts index 889a233..6354315 100644 --- a/src/shared/windowState.ts +++ b/src/shared/windowState.ts @@ -4,8 +4,36 @@ import { WINDOW_LOCAL_STATE_FORMAT, WINDOW_LOCAL_STATE_VERSION, type PrismWindowLocalStateV1, + type WindowBackgroundMode, + type WindowBackgroundState, } from '../types/windowState' +const WINDOW_BACKGROUND_MODES: readonly WindowBackgroundMode[] = ['solid', 'blurred', 'clear'] + +export function createDefaultWindowBackgroundState(): WindowBackgroundState { + return { + mode: 'solid', + transparency: 50, + } +} + +export function normalizeWindowBackgroundState(raw: unknown): WindowBackgroundState { + const fallback = createDefaultWindowBackgroundState() + if (typeof raw !== 'object' || raw === null) { + return fallback + } + + const candidate = raw as Partial + const mode = WINDOW_BACKGROUND_MODES.includes(candidate.mode as WindowBackgroundMode) + ? candidate.mode as WindowBackgroundMode + : fallback.mode + const transparency = typeof candidate.transparency === 'number' && Number.isFinite(candidate.transparency) + ? Math.min(100, Math.max(0, Math.round(candidate.transparency))) + : fallback.transparency + + return { mode, transparency } +} + export function createEmptyWindowLocalState(): PrismWindowLocalStateV1 { return { format: WINDOW_LOCAL_STATE_FORMAT, @@ -13,6 +41,7 @@ export function createEmptyWindowLocalState(): PrismWindowLocalStateV1 { mainAlwaysOnTop: false, popoutAlwaysOnTop: {}, nowPlayingConfigWindowBounds: undefined, + windowBackground: createDefaultWindowBackgroundState(), } } @@ -37,5 +66,6 @@ export function normalizeWindowLocalState(raw: unknown): PrismWindowLocalStateV1 mainAlwaysOnTop: parsed.mainAlwaysOnTop === true, popoutAlwaysOnTop, nowPlayingConfigWindowBounds: normalizeWindowBounds(parsed.nowPlayingConfigWindowBounds), + windowBackground: normalizeWindowBackgroundState(parsed.windowBackground), } } diff --git a/src/types/nativeWindowChrome.ts b/src/types/nativeWindowChrome.ts index e98c2d3..d63351e 100644 --- a/src/types/nativeWindowChrome.ts +++ b/src/types/nativeWindowChrome.ts @@ -4,4 +4,8 @@ export interface NativeWindowChromeAPI { // was invalid or the subclass could not be installed. Windows-only; a no-op // elsewhere. applyFlatFrame: (nativeWindowHandle: Buffer) => boolean + // Accent-policy acrylic blur (SetWindowCompositionAttribute). Unlike the DWM + // system backdrop, the blur persists while the window is unfocused and works + // on borderless transparent windows. Windows-only; a no-op elsewhere. + setAcrylicBlurBehind: (nativeWindowHandle: Buffer, enable: boolean) => boolean } diff --git a/src/types/windowCapabilities.ts b/src/types/windowCapabilities.ts index 49a6b68..f766131 100644 --- a/src/types/windowCapabilities.ts +++ b/src/types/windowCapabilities.ts @@ -5,4 +5,5 @@ export interface WindowCapabilities { useNativeDragRegions: boolean supportsProgrammaticReposition: boolean supportsGeometryPersistence: boolean + supportsBlurredBackground: boolean } diff --git a/src/types/windowState.ts b/src/types/windowState.ts index b16f141..1364c14 100644 --- a/src/types/windowState.ts +++ b/src/types/windowState.ts @@ -4,10 +4,26 @@ import type { WindowBounds } from './popout' export const WINDOW_LOCAL_STATE_FORMAT = 'prism-window-local' export const WINDOW_LOCAL_STATE_VERSION = 1 +export type WindowBackgroundMode = 'solid' | 'blurred' | 'clear' + +export interface WindowBackgroundState { + mode: WindowBackgroundMode + /** How much of the desktop shows through the app background, 0-100. */ + transparency: number +} + +export interface WindowBackgroundSnapshot { + /** What the user chose (preserved even when the platform can't honor it). */ + stored: WindowBackgroundState + /** What is actually applied after platform capability downgrades. */ + effective: WindowBackgroundState +} + export interface PrismWindowLocalStateV1 { format: typeof WINDOW_LOCAL_STATE_FORMAT version: typeof WINDOW_LOCAL_STATE_VERSION mainAlwaysOnTop: boolean popoutAlwaysOnTop: Partial> nowPlayingConfigWindowBounds?: WindowBounds + windowBackground: WindowBackgroundState } diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index c6afaa0..38146f1 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -3130,6 +3130,7 @@ test('resolveWindowCapabilities detects native Wayland sessions on Linux', () => useNativeDragRegions: true, supportsProgrammaticReposition: false, supportsGeometryPersistence: false, + supportsBlurredBackground: false, }, ) }) @@ -3150,6 +3151,7 @@ test('resolveWindowCapabilities respects --ozone-platform=x11 in a Wayland sessi useNativeDragRegions: false, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, + supportsBlurredBackground: false, }, ) }) @@ -3169,6 +3171,7 @@ test('resolveWindowCapabilities detects X11 sessions on Linux', () => { useNativeDragRegions: false, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, + supportsBlurredBackground: false, }, ) }) @@ -3185,6 +3188,7 @@ test('resolveWindowCapabilities uses native drag regions on macOS while preservi useNativeDragRegions: true, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, + supportsBlurredBackground: true, }, ) }) @@ -3201,10 +3205,26 @@ test('resolveWindowCapabilities uses native drag regions on Windows while preser useNativeDragRegions: true, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, + supportsBlurredBackground: false, }, ) }) +test('resolveWindowCapabilities gates blurred backgrounds on the Windows 11 22H2 build', () => { + const resolveForBuild = (osVersion?: string) => resolveWindowCapabilities({ + platform: 'win32', + argv: [], + env: {}, + osVersion, + }).supportsBlurredBackground + + assert.equal(resolveForBuild('10.0.22621'), true) + assert.equal(resolveForBuild('10.0.26200'), true) + assert.equal(resolveForBuild('10.0.19045'), false) + assert.equal(resolveForBuild(undefined), false) + assert.equal(resolveForBuild('not-a-version'), false) +}) + test('Wayland window controls use native drag regions and omit unsupported reposition/geometry paths', async () => { const toolbarSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'Toolbar.tsx'), 'utf8') const appSource = await readFile(join(process.cwd(), 'src', 'renderer', 'App.tsx'), 'utf8') @@ -3228,14 +3248,17 @@ test('main and detached windows keep frameless Prism chrome while enabling snap- const popoutSource = await readFile(join(process.cwd(), 'src', 'renderer', 'popouts', 'ScopePopoutWindow.tsx'), 'utf8') const nowPlayingSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'NowPlayingConfigWindow.tsx'), 'utf8') - assert.match(mainSource, /function getSnapCapableFramelessWindowOptions\(\): Pick<[\s\S]*?return \{[\s\S]*?frame: false,[\s\S]*?roundedCorners: false,[\s\S]*?hasShadow: false,[\s\S]*?thickFrame: true,[\s\S]*?resizable: true,[\s\S]*?maximizable: true,[\s\S]*?fullscreenable: true,[\s\S]*?skipTaskbar: false,[\s\S]*?\}/) - assert.match(mainSource, /function createMainWindow\(\): void \{[\s\S]*?\.\.\.getSnapCapableFramelessWindowOptions\(\),/) - assert.match(mainSource, /function createScopePopoutWindow\(kind: ScopeKind, rawBounds\?: WindowBounds\): BrowserWindow \| null \{[\s\S]*?\.\.\.getSnapCapableFramelessWindowOptions\(\),/) - assert.match(mainSource, /function createNowPlayingConfigWindow\(\): BrowserWindow \{[\s\S]*?\.\.\.getSnapCapableFramelessWindowOptions\(\),/) + assert.match(mainSource, /function getFramelessWindowOptions\([\s\S]*?return \{[\s\S]*?frame: false,[\s\S]*?transparent: background\.mode === 'clear' \|\| transparentOnWindows,[\s\S]*?roundedCorners: false,[\s\S]*?hasShadow: false,[\s\S]*?thickFrame: background\.mode === 'solid',[\s\S]*?backgroundMaterial: 'none'[\s\S]*?resizable: true,[\s\S]*?maximizable: true,[\s\S]*?fullscreenable: true,[\s\S]*?skipTaskbar: false,[\s\S]*?\}/) + assert.match(mainSource, /function createMainWindow\(restoreBounds\?: WindowBounds\): void \{[\s\S]*?\.\.\.getFramelessWindowOptions\(background\),/) + assert.match(mainSource, /function createScopePopoutWindow\(kind: ScopeKind, rawBounds\?: WindowBounds\): BrowserWindow \| null \{[\s\S]*?\.\.\.getFramelessWindowOptions\(background\),/) + assert.match(mainSource, /function createNowPlayingConfigWindow\(\): BrowserWindow \{[\s\S]*?\.\.\.getFramelessWindowOptions\(\),/) assert.match(popoutSource, /useWindowManagerDragRegions = getRendererWindowCapabilities\(\)\.useNativeDragRegions/) assert.match(nowPlayingSource, /useWindowManagerDragRegions = getRendererWindowCapabilities\(\)\.useNativeDragRegions/) - assert.doesNotMatch(appSource, /WindowResizeOverlay/) - assert.doesNotMatch(popoutSource, /WindowResizeOverlay/) + // Blurred and clear windows drop the native thick frame on Windows, so the + // JS resize overlay mounts for both; the now-playing config window always + // keeps native semantics. + assert.match(appSource, /windowBackgroundMode !== 'solid' && /) + assert.match(popoutSource, /windowBackgroundMode !== 'solid' && /) assert.doesNotMatch(nowPlayingSource, /WindowResizeOverlay/) }) diff --git a/test/window-state-store.test.ts b/test/window-state-store.test.ts index dc83e5e..526f44a 100644 --- a/test/window-state-store.test.ts +++ b/test/window-state-store.test.ts @@ -132,6 +132,44 @@ test('turning a popout pin back off clears its saved local state without affecti } }) +test('window background defaults to solid and persists mode plus transparency', async () => { + const harness = await createHarness() + + try { + await harness.store.initialize() + assert.deepEqual(harness.store.getWindowBackground(), { mode: 'solid', transparency: 50 }) + + await harness.store.setWindowBackground({ mode: 'blurred', transparency: 72 }) + + const reloaded = new FileBackedWindowStateStore(harness.localStatePath) + await reloaded.initialize() + assert.deepEqual(reloaded.getWindowBackground(), { mode: 'blurred', transparency: 72 }) + } finally { + await harness.cleanup() + } +}) + +test('invalid window background normalizes to solid mode and clamped transparency', async () => { + const harness = await createHarness() + + try { + await mkdir(dirname(harness.localStatePath), { recursive: true }) + await writeFile(harness.localStatePath, `${JSON.stringify({ + format: WINDOW_LOCAL_STATE_FORMAT, + version: WINDOW_LOCAL_STATE_VERSION, + windowBackground: { mode: 'frosted', transparency: 240.7 }, + }, null, 2)}\n`, 'utf8') + + await harness.store.initialize() + assert.deepEqual(harness.store.getWindowBackground(), { mode: 'solid', transparency: 100 }) + + await harness.store.setWindowBackground({ mode: 'clear', transparency: -10 }) + assert.deepEqual(harness.store.getWindowBackground(), { mode: 'clear', transparency: 0 }) + } finally { + await harness.cleanup() + } +}) + test('invalid saved window state normalizes to supported keys and boolean values', async () => { const harness = await createHarness()