initial transparency

This commit is contained in:
Boof2015
2026-06-11 17:30:07 -04:00
parent c56b86ba63
commit 0ee95fe480
20 changed files with 753 additions and 27 deletions
+85
View File
@@ -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<uint8_t> handle = info[0].As<Napi::Buffer<uint8_t>>();
if (handle.Length() < sizeof(HWND)) {
Napi::TypeError::New(env, "Window handle Buffer is too small")
.ThrowAsJavaScriptException();
return nullptr;
}
HWND hwnd = *reinterpret_cast<HWND*>(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<Napi::Boolean>().Value()
: true;
HMODULE user32 = GetModuleHandleW(L"user32.dll");
if (user32 == nullptr) {
return Napi::Boolean::New(env, false);
}
auto setWindowCompositionAttribute = reinterpret_cast<SetWindowCompositionAttributeFn>(
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);
}
+169 -16
View File
@@ -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<ScopeKind, BrowserWindow>()
const scopePopoutCloseAllowed = new Set<ScopeKind>()
@@ -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<string, string> {
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<DialogResult> {
})
}
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()
})
+15 -1
View File
@@ -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<void> {
await this.ensureInitialized()
this.state = normalizeWindowLocalState({
...this.state,
windowBackground: background,
popoutAlwaysOnTop: { ...this.state.popoutAlwaysOnTop },
})
await this.persistState()
}
async setNowPlayingConfigWindowBounds(bounds?: WindowBounds): Promise<void> {
await this.ensureInitialized()
this.state = normalizeWindowLocalState({
+9
View File
@@ -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<WindowBackgroundSnapshot>,
setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise<WindowBackgroundSnapshot>,
isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise<boolean>,
getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport,
getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise<NowPlayingState>,
@@ -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)
+9
View File
@@ -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 {
<BottomBar onClose={handleCloseSettings} onHeightChange={setBottomBarHeight} />
</div>
{windowBackgroundMode !== 'solid' && <WindowResizeOverlay />}
</div>
)
}
+101
View File
@@ -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<ScopeKind, string> = {
@@ -38,6 +41,22 @@ const FRAME_TARGET_LABELS: Record<VisualizerFrameTarget, string> = {
const DEFAULT_INPUT_DEVICE_ID = '__default_input__'
const WINDOW_BACKGROUND_MODES: readonly WindowBackgroundMode[] = ['solid', 'blurred', 'clear']
const WINDOW_BACKGROUND_MODE_LABELS: Record<WindowBackgroundMode, string> = {
solid: 'Solid',
blurred: 'Blurred',
clear: 'Clear',
}
const WINDOW_BACKGROUND_MODE_TITLES: Record<WindowBackgroundMode, string> = {
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<HTMLDivElement | null>(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<WindowBackgroundState | null>(null)
const windowBackgroundFlushTimerRef = useRef<ReturnType<typeof setTimeout> | 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):
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--window">
<div className="bottom-bar__section-header">
<div className="bottom-bar__section-title">Window</div>
{windowBackground.mode !== 'solid' ? (
<span className="bottom-bar__window-note">
Window snapping is disabled in this mode
</span>
) : null}
</div>
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--window">
<div className="bottom-bar__inline bottom-bar__inline--chips">
{WINDOW_BACKGROUND_MODES.map((mode) => {
const unsupported = mode === 'blurred' && !supportsBlurredBackground
return (
<button
key={mode}
type="button"
className={`settings-chip ${windowBackground.mode === mode ? 'is-active' : ''}`.trim()}
onClick={() => handleWindowBackgroundMode(mode)}
disabled={unsupported}
title={unsupported
? 'Blurred background requires Windows 11'
: WINDOW_BACKGROUND_MODE_TITLES[mode]}
>
{WINDOW_BACKGROUND_MODE_LABELS[mode]}
</button>
)
})}
</div>
<span className="bottom-bar__trim-value">
{windowBackground.transparency}%
</span>
<input
className="settings-control__range bottom-bar__trim-slider"
type="range"
min={0}
max={100}
step={1}
value={windowBackground.transparency}
disabled={windowBackground.mode === 'solid'}
style={{ '--range-percent': `${windowBackground.transparency}%` } as CSSProperties}
onChange={(event) => {
queueWindowBackgroundSave({
...windowBackground,
transparency: Number(event.target.value),
})
}}
title="How much of the desktop shows through"
/>
</div>
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--now-playing">
<div className="bottom-bar__section-header bottom-bar__section-header--now-playing">
<div className="bottom-bar__section-title">Now Playing</div>
+38 -4
View File
@@ -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<string, unknown>,
windowBgAlpha: number,
): Record<string, unknown> {
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
+4
View File
@@ -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<boolean>
getWindowBackground: () => Promise<WindowBackgroundSnapshot>
setWindowBackground: (state: WindowBackgroundState) => Promise<WindowBackgroundSnapshot>
isCursorInsideWindow: () => Promise<boolean>
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
getNowPlayingState: () => Promise<NowPlayingState>
@@ -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
+5
View File
@@ -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 = <DialogApp />
@@ -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)
</div>
</div>
)}
{windowBackgroundMode !== 'solid' && <WindowResizeOverlay />}
</div>
)
}
@@ -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<void>
/** Apply locally without persisting — used while dragging the slider. */
previewBackground: (state: WindowBackgroundState) => void
setBackground: (state: WindowBackgroundState) => Promise<void>
}
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<WindowBackgroundStoreState>((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))
}
+50
View File
@@ -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;
+38
View File
@@ -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)
}
}
+19
View File
@@ -4,6 +4,7 @@ interface WindowCapabilityResolutionOptions {
platform: string
argv?: readonly string[]
env?: Record<string, string | undefined>
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,
}
}
+30
View File
@@ -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<WindowBackgroundState>
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),
}
}
+4
View File
@@ -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
}
+1
View File
@@ -5,4 +5,5 @@ export interface WindowCapabilities {
useNativeDragRegions: boolean
supportsProgrammaticReposition: boolean
supportsGeometryPersistence: boolean
supportsBlurredBackground: boolean
}
+16
View File
@@ -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<Record<ScopeKind, boolean>>
nowPlayingConfigWindowBounds?: WindowBounds
windowBackground: WindowBackgroundState
}
+29 -6
View File
@@ -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' && <WindowResizeOverlay \/>/)
assert.match(popoutSource, /windowBackgroundMode !== 'solid' && <WindowResizeOverlay \/>/)
assert.doesNotMatch(nowPlayingSource, /WindowResizeOverlay/)
})
+38
View File
@@ -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()