This commit is contained in:
22
overlay/index.html
Normal file
22
overlay/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src ws://127.0.0.1:8000; script-src 'self'; style-src 'self';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Custom Streamdeck Overlay</title>
|
||||
<link rel="stylesheet" href="./dist/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="overlay" class="overlay hidden" aria-live="polite">
|
||||
<section class="panel">
|
||||
<h1 id="profile-name">Profile</h1>
|
||||
<div id="button-strip" class="button-strip"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="./dist/renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4663
overlay/package-lock.json
generated
Normal file
4663
overlay/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
overlay/package.json
Normal file
58
overlay/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "custom-streamdeck-overlay",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Transparent Streamdeck profile overlay.",
|
||||
"author": "Custom Streamdeck",
|
||||
"main": "dist/main.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"build": "npm run build:css && tsc -p tsconfig.json",
|
||||
"build:css": "tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify",
|
||||
"dist": "npm run dist:win",
|
||||
"dist:win": "npm run build && electron-builder --win nsis --x64 --publish never",
|
||||
"watch:css": "tailwindcss -i ./src/styles.css -o ./dist/styles.css --watch",
|
||||
"dev": "npm run build && electron .",
|
||||
"start": "electron ."
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.customstreamdeck.overlay",
|
||||
"productName": "Custom Streamdeck Overlay",
|
||||
"asar": true,
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"index.html",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"signAndEditExecutable": false,
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-Setup-${arch}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": false,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Custom Streamdeck Overlay"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.3.0",
|
||||
"electron": "^42.0.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
9
overlay/src/global.d.ts
vendored
Normal file
9
overlay/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
type OverlayBridge = {
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
onReveal: (callback: () => void) => void;
|
||||
};
|
||||
|
||||
interface Window {
|
||||
streamdeckOverlay?: OverlayBridge;
|
||||
}
|
||||
167
overlay/src/main.ts
Normal file
167
overlay/src/main.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, Tray } from "electron";
|
||||
import path from "node:path";
|
||||
|
||||
const OVERLAY_WIDTH = 820;
|
||||
const OVERLAY_HEIGHT = 150;
|
||||
const SCREEN_EDGE_MARGIN = 24;
|
||||
const TASKBAR_GAP = 14;
|
||||
const APP_USER_MODEL_ID = "com.customstreamdeck.overlay";
|
||||
const TRAY_ICON_DATA_URL =
|
||||
"data:image/png;base64," +
|
||||
"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAkUlEQVR4nGNgwAMEJdT/UwPjs4NmlpLlGFpbjtcR9LIcqyPobTmGI0a2AwbKcrgjiFGkdDSOLEwVB5BrObGOGPwOGPAoGBSJcNQBFnt/kIWp4gByLSfWEYPfAQMeBYMiEY464GexGF5MUwcQspxSRwy8A4hpFdHU8gFvEw4KBwx4x2RQdM3o5Qi8ltPKMfjsAABiL54YN/wECQAAAABJRU5ErkJggg==";
|
||||
|
||||
let overlayWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let isQuitting = false;
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function createOverlayWindow(): void {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
const width = Math.min(OVERLAY_WIDTH, display.workArea.width - SCREEN_EDGE_MARGIN * 2);
|
||||
const height = OVERLAY_HEIGHT;
|
||||
const { x, y } = overlayPosition(display, width, height);
|
||||
|
||||
overlayWindow = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false,
|
||||
show: false,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
});
|
||||
|
||||
overlayWindow.setIgnoreMouseEvents(true);
|
||||
overlayWindow.setAlwaysOnTop(true, "screen-saver");
|
||||
overlayWindow.loadFile(path.join(__dirname, "..", "index.html"));
|
||||
overlayWindow.once("ready-to-show", () => {
|
||||
console.log("[overlay:main] window ready; keeping transparent click-through overlay alive");
|
||||
overlayWindow?.showInactive();
|
||||
overlayWindow?.setIgnoreMouseEvents(true);
|
||||
});
|
||||
overlayWindow.webContents.on("console-message", details => {
|
||||
console.log(`[overlay:renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`);
|
||||
});
|
||||
|
||||
overlayWindow.on("closed", () => {
|
||||
console.log("[overlay:main] window closed");
|
||||
overlayWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
function createTray(): void {
|
||||
if (tray) {
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = nativeImage.createFromDataURL(TRAY_ICON_DATA_URL);
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip("Custom Streamdeck Overlay");
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Show overlay",
|
||||
click: revealOverlayFromTray
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
])
|
||||
);
|
||||
tray.on("click", revealOverlayFromTray);
|
||||
}
|
||||
|
||||
function showOverlay(): void {
|
||||
if (!overlayWindow) {
|
||||
console.log("[overlay:main] show requested before window exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
|
||||
const [width, height] = overlayWindow.getSize();
|
||||
console.log(`[overlay:main] showing overlay at ${width}x${height} on display ${display.id}`);
|
||||
overlayWindow.setBounds({
|
||||
width,
|
||||
height,
|
||||
...overlayPosition(display, width, height)
|
||||
});
|
||||
|
||||
overlayWindow.showInactive();
|
||||
overlayWindow.setAlwaysOnTop(true, "screen-saver");
|
||||
overlayWindow.setIgnoreMouseEvents(true);
|
||||
}
|
||||
|
||||
function hideOverlay(): void {
|
||||
// Keep the BrowserWindow alive so the hidden renderer continues receiving websocket events.
|
||||
console.log("[overlay:main] overlay faded; leaving window alive for websocket events");
|
||||
overlayWindow?.setIgnoreMouseEvents(true);
|
||||
}
|
||||
|
||||
function revealOverlayFromTray(): void {
|
||||
if (!overlayWindow) {
|
||||
createOverlayWindow();
|
||||
}
|
||||
|
||||
overlayWindow?.webContents.send("overlay:reveal");
|
||||
showOverlay();
|
||||
}
|
||||
|
||||
if (hasSingleInstanceLock) {
|
||||
app.on("second-instance", revealOverlayFromTray);
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.setAppUserModelId(APP_USER_MODEL_ID);
|
||||
console.log("[overlay:main] app ready");
|
||||
createTray();
|
||||
createOverlayWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createOverlayWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (isQuitting) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("overlay:show", showOverlay);
|
||||
ipcMain.on("overlay:hide", hideOverlay);
|
||||
|
||||
function overlayPosition(display: Electron.Display, width: number, height: number): { x: number; y: number } {
|
||||
const x = Math.round(display.workArea.x + (display.workArea.width - width) / 2);
|
||||
const bottomAlignedY = display.workArea.y + display.workArea.height - height - TASKBAR_GAP;
|
||||
const y = Math.round(Math.max(display.workArea.y + SCREEN_EDGE_MARGIN, bottomAlignedY));
|
||||
return { x, y };
|
||||
}
|
||||
9
overlay/src/preload.ts
Normal file
9
overlay/src/preload.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("streamdeckOverlay", {
|
||||
show: () => ipcRenderer.send("overlay:show"),
|
||||
hide: () => ipcRenderer.send("overlay:hide"),
|
||||
onReveal: (callback: () => void) => {
|
||||
ipcRenderer.on("overlay:reveal", callback);
|
||||
}
|
||||
});
|
||||
327
overlay/src/renderer.ts
Normal file
327
overlay/src/renderer.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
type ActionType = "noop" | "key_combo" | "chain" | "app_launch" | "folder" | "folder_rotation" | "plugin";
|
||||
|
||||
type Settings = {
|
||||
active_profile_id: string;
|
||||
active_folder_id: string;
|
||||
};
|
||||
|
||||
type Profile = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Folder = {
|
||||
id: string;
|
||||
name: string;
|
||||
profile_id: string;
|
||||
};
|
||||
|
||||
type ButtonConfig = {
|
||||
id: string;
|
||||
folder_id: string;
|
||||
position: number;
|
||||
label: string;
|
||||
color: string;
|
||||
action_type: ActionType;
|
||||
action_config: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type AppEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type PluginAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type PluginInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
actions: PluginAction[];
|
||||
};
|
||||
|
||||
type DeckState = {
|
||||
settings: Settings;
|
||||
profiles: Profile[];
|
||||
folders: Folder[];
|
||||
buttons: ButtonConfig[];
|
||||
apps: AppEntry[];
|
||||
plugins: PluginInfo[];
|
||||
};
|
||||
|
||||
type WebSocketMessage = {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
type ButtonEventPayload = {
|
||||
event?: {
|
||||
button?: unknown;
|
||||
event?: unknown;
|
||||
pressed?: unknown;
|
||||
};
|
||||
button?: {
|
||||
position?: unknown;
|
||||
physical_button?: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const WS_URL = "ws://127.0.0.1:8000/ws";
|
||||
const VISIBLE_MS = 7000;
|
||||
const FADE_MS = 450;
|
||||
const RELEASE_HOLD_MS = 120;
|
||||
const MAX_RECONNECT_MS = 10000;
|
||||
|
||||
const overlay = mustFind<HTMLElement>("overlay");
|
||||
const profileName = mustFind<HTMLElement>("profile-name");
|
||||
const buttonStrip = mustFind<HTMLElement>("button-strip");
|
||||
|
||||
let lastProfileId: string | null = null;
|
||||
let lastFolderId: string | null = null;
|
||||
let hideTimer: number | null = null;
|
||||
let fadeTimer: number | null = null;
|
||||
let enterTimer: number | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
let currentState: DeckState | null = null;
|
||||
const pressedPositions = new Set<number>();
|
||||
const releaseTimers = new Map<number, number>();
|
||||
|
||||
log("booting renderer");
|
||||
window.streamdeckOverlay?.onReveal(() => {
|
||||
if (currentState) {
|
||||
renderState(currentState);
|
||||
}
|
||||
showOverlay();
|
||||
});
|
||||
connect();
|
||||
|
||||
function connect(): void {
|
||||
log(`connecting to ${WS_URL}`);
|
||||
const socket = new WebSocket(WS_URL);
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
reconnectAttempt = 0;
|
||||
log("websocket connected");
|
||||
});
|
||||
|
||||
socket.addEventListener("message", event => {
|
||||
const message = parseMessage(event.data);
|
||||
log(`websocket message: ${message?.type ?? "unparseable"}`);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "state.updated" && isDeckState(message.payload)) {
|
||||
handleState(message.payload);
|
||||
return;
|
||||
}
|
||||
if ((message.type === "button.down" || message.type === "button.up") && isButtonEventPayload(message.payload)) {
|
||||
handleButtonEvent(message.type, message.payload);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", scheduleReconnect);
|
||||
socket.addEventListener("error", () => {
|
||||
log("websocket error; closing socket so reconnect can run");
|
||||
socket.close();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
const delay = Math.min(500 * 2 ** reconnectAttempt, MAX_RECONNECT_MS);
|
||||
log(`websocket closed; reconnecting in ${delay}ms`);
|
||||
reconnectAttempt += 1;
|
||||
window.setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
function handleState(state: DeckState): void {
|
||||
currentState = state;
|
||||
const profileId = state.settings.active_profile_id;
|
||||
const folderId = state.settings.active_folder_id;
|
||||
const shouldShow = lastProfileId === null || lastProfileId !== profileId || lastFolderId !== folderId;
|
||||
log(
|
||||
`state.updated profile=${shortId(profileId)} folder=${shortId(folderId)} ` +
|
||||
`lastProfile=${shortId(lastProfileId)} lastFolder=${shortId(lastFolderId)} shouldShow=${shouldShow}`
|
||||
);
|
||||
|
||||
lastProfileId = profileId;
|
||||
lastFolderId = folderId;
|
||||
|
||||
if (!shouldShow) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderState(state);
|
||||
showOverlay();
|
||||
}
|
||||
|
||||
function handleButtonEvent(type: "button.down" | "button.up", payload: ButtonEventPayload): void {
|
||||
const position = buttonPositionFromPayload(payload);
|
||||
if (position === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (releaseTimers.has(position)) {
|
||||
window.clearTimeout(releaseTimers.get(position));
|
||||
releaseTimers.delete(position);
|
||||
}
|
||||
|
||||
if (type === "button.down") {
|
||||
pressedPositions.add(position);
|
||||
if (currentState && buttonStrip.children.length === 0) {
|
||||
renderState(currentState);
|
||||
}
|
||||
showOverlay({ animate: false });
|
||||
updatePressedButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
releaseTimers.set(
|
||||
position,
|
||||
window.setTimeout(() => {
|
||||
pressedPositions.delete(position);
|
||||
releaseTimers.delete(position);
|
||||
updatePressedButtons();
|
||||
}, RELEASE_HOLD_MS)
|
||||
);
|
||||
}
|
||||
|
||||
function renderState(state: DeckState): void {
|
||||
const activeProfile = state.profiles.find(profile => profile.id === state.settings.active_profile_id);
|
||||
const activeFolder = state.folders.find(folder => folder.id === state.settings.active_folder_id);
|
||||
const buttons = state.buttons
|
||||
.filter(button => button.folder_id === state.settings.active_folder_id)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.slice(0, 10);
|
||||
|
||||
const profileLabel = activeProfile?.name || "Unknown Profile";
|
||||
const folderLabel = activeFolder?.name || "Unknown Folder";
|
||||
|
||||
log(`rendering profile="${profileLabel}" folder="${folderLabel}" buttons=${buttons.length}`);
|
||||
profileName.textContent = `${profileLabel} / ${folderLabel}`;
|
||||
buttonStrip.replaceChildren(...Array.from({ length: 10 }, (_, index) => renderButton(buttons[index], index + 1)));
|
||||
}
|
||||
|
||||
function renderButton(button: ButtonConfig | undefined, fallbackPosition: number): HTMLElement {
|
||||
const tile = document.createElement("article");
|
||||
tile.className = "button-tile";
|
||||
if (pressedPositions.has(fallbackPosition)) {
|
||||
tile.classList.add("pressed");
|
||||
}
|
||||
tile.dataset.position = String(fallbackPosition);
|
||||
tile.style.setProperty("--button-color", button?.color || "#6b7280");
|
||||
tile.style.setProperty("--tile-index", String(fallbackPosition - 1));
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "button-label";
|
||||
label.textContent = button?.label?.trim() || `Button ${fallbackPosition}`;
|
||||
|
||||
tile.append(label);
|
||||
return tile;
|
||||
}
|
||||
|
||||
function updatePressedButtons(): void {
|
||||
buttonStrip.querySelectorAll<HTMLElement>(".button-tile").forEach(tile => {
|
||||
const position = numberValue(tile.dataset.position);
|
||||
tile.classList.toggle("pressed", position !== null && pressedPositions.has(position));
|
||||
});
|
||||
}
|
||||
|
||||
function showOverlay(options: { animate?: boolean } = {}): void {
|
||||
log("showing overlay");
|
||||
const animate = options.animate ?? true;
|
||||
|
||||
if (hideTimer !== null) {
|
||||
window.clearTimeout(hideTimer);
|
||||
}
|
||||
if (fadeTimer !== null) {
|
||||
window.clearTimeout(fadeTimer);
|
||||
}
|
||||
if (enterTimer !== null) {
|
||||
window.clearTimeout(enterTimer);
|
||||
}
|
||||
|
||||
overlay.classList.remove("hidden", "decaying");
|
||||
overlay.classList.remove("entering");
|
||||
if (animate) {
|
||||
void overlay.offsetWidth;
|
||||
overlay.classList.add("entering");
|
||||
}
|
||||
window.streamdeckOverlay?.show();
|
||||
|
||||
if (animate) {
|
||||
enterTimer = window.setTimeout(() => {
|
||||
overlay.classList.remove("entering");
|
||||
}, 420);
|
||||
}
|
||||
|
||||
hideTimer = window.setTimeout(() => {
|
||||
log("starting overlay fade");
|
||||
overlay.classList.add("decaying");
|
||||
fadeTimer = window.setTimeout(() => {
|
||||
log("overlay hidden in DOM");
|
||||
overlay.classList.add("hidden");
|
||||
window.streamdeckOverlay?.hide();
|
||||
}, FADE_MS);
|
||||
}, VISIBLE_MS - FADE_MS);
|
||||
}
|
||||
|
||||
function parseMessage(data: unknown): WebSocketMessage | null {
|
||||
if (typeof data !== "string") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(data) as WebSocketMessage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isDeckState(value: unknown): value is DeckState {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const state = value as Partial<DeckState>;
|
||||
return Boolean(
|
||||
state.settings &&
|
||||
Array.isArray(state.profiles) &&
|
||||
Array.isArray(state.folders) &&
|
||||
Array.isArray(state.buttons) &&
|
||||
Array.isArray(state.apps) &&
|
||||
Array.isArray(state.plugins)
|
||||
);
|
||||
}
|
||||
|
||||
function isButtonEventPayload(value: unknown): value is ButtonEventPayload {
|
||||
return Boolean(value && typeof value === "object");
|
||||
}
|
||||
|
||||
function buttonPositionFromPayload(payload: ButtonEventPayload): number | null {
|
||||
const mappedPosition = numberValue(payload.button?.position);
|
||||
if (mappedPosition !== null) {
|
||||
return mappedPosition;
|
||||
}
|
||||
return numberValue(payload.event?.button);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
||||
return Number.isInteger(number) && number >= 1 && number <= 10 ? number : null;
|
||||
}
|
||||
|
||||
function shortId(value: string | null): string {
|
||||
return value ? value.slice(0, 8) : "null";
|
||||
}
|
||||
|
||||
function log(message: string): void {
|
||||
console.log(`[overlay] ${message}`);
|
||||
}
|
||||
|
||||
function mustFind<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing element #${id}`);
|
||||
}
|
||||
return element as T;
|
||||
}
|
||||
156
overlay/src/styles.css
Normal file
156
overlay/src/styles.css
Normal file
@@ -0,0 +1,156 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../index.html";
|
||||
@source "./**/*.ts";
|
||||
|
||||
@theme {
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply h-full overflow-hidden bg-transparent select-none;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0 flex items-center justify-center;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.overlay {
|
||||
@apply flex h-full w-full items-center justify-center p-2 opacity-100;
|
||||
transform: translateY(0) scale(1);
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.overlay.hidden {
|
||||
@apply opacity-0;
|
||||
transform: translateY(7px) scale(0.985);
|
||||
}
|
||||
|
||||
.overlay.decaying {
|
||||
@apply opacity-0;
|
||||
transform: translateY(6px) scale(0.992);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply w-full max-w-full overflow-hidden text-white;
|
||||
background: rgba(58, 59, 61, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.3);
|
||||
padding: 17px 18px 18px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply mb-4 overflow-hidden text-center text-lg leading-tight text-ellipsis whitespace-nowrap text-white;
|
||||
font-weight: 650;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.button-strip {
|
||||
@apply grid grid-cols-10 gap-[5px];
|
||||
}
|
||||
|
||||
.button-tile {
|
||||
@apply relative flex min-h-[54px] min-w-0 items-center overflow-hidden px-1.5 pt-[7px] pb-2;
|
||||
background: rgba(255, 255, 255, 0.095);
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.button-tile::before {
|
||||
@apply absolute top-0 right-0 left-0 content-[''];
|
||||
background: var(--button-color);
|
||||
height: 4px;
|
||||
opacity: 0.68;
|
||||
transition:
|
||||
height 120ms ease,
|
||||
opacity 120ms ease;
|
||||
}
|
||||
|
||||
.button-label {
|
||||
@apply relative z-10 line-clamp-2 w-full min-w-0 overflow-hidden text-center text-xs leading-tight text-ellipsis text-white;
|
||||
display: -webkit-box;
|
||||
font-weight: 650;
|
||||
-webkit-box-orient: vertical;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.overlay.entering .panel {
|
||||
animation: osd-pop 280ms cubic-bezier(0.2, 0.95, 0.22, 1) both;
|
||||
}
|
||||
|
||||
.overlay.entering .button-tile {
|
||||
animation: tile-rise 220ms ease-out both;
|
||||
animation-delay: calc(45ms + (var(--tile-index) * 16ms));
|
||||
}
|
||||
|
||||
.button-tile.pressed {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
inset 0 2px 7px rgba(255, 255, 255, 0.08),
|
||||
0 1px 2px rgba(0, 0, 0, 0.22);
|
||||
transform: translateY(1px) scale(0.975);
|
||||
}
|
||||
|
||||
.button-tile.pressed::before {
|
||||
height: 6px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes osd-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.975);
|
||||
}
|
||||
|
||||
62% {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px) scale(1.006);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tile-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.overlay,
|
||||
.panel,
|
||||
.button-tile {
|
||||
animation: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
}
|
||||
15
overlay/tsconfig.json
Normal file
15
overlay/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user