mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
initial transparency
This commit is contained in:
@@ -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
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+4
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export interface WindowCapabilities {
|
||||
useNativeDragRegions: boolean
|
||||
supportsProgrammaticReposition: boolean
|
||||
supportsGeometryPersistence: boolean
|
||||
supportsBlurredBackground: boolean
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user