From 65488ae59eb9beeccba1d5c0e811df6e81b56082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Mon, 5 Jan 2026 11:34:53 +0100 Subject: [PATCH] Add prettier --- .prettierrc | 13 + package-lock.json | 79 +- package.json | 19 +- src/main/node/app.js | 138 +- src/main/resources/static/css/styles.css | 2310 ++++++------ src/main/resources/static/js/admin.js | 3528 ++++++++++--------- src/main/resources/static/js/broadcast.js | 1375 ++++---- src/main/resources/static/js/dashboard.js | 356 +- src/main/resources/static/js/downloads.js | 62 +- src/main/resources/static/js/landing.js | 94 +- src/main/resources/static/js/settings.js | 176 +- src/main/resources/static/js/toast.js | 82 +- src/main/resources/templates/admin.html | 470 ++- src/main/resources/templates/broadcast.html | 24 +- src/main/resources/templates/channels.html | 62 +- src/main/resources/templates/dashboard.html | 243 +- src/main/resources/templates/index.html | 130 +- src/main/resources/templates/settings.html | 482 +-- 18 files changed, 4981 insertions(+), 4662 deletions(-) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2216ede --- /dev/null +++ b/.prettierrc @@ -0,0 +1,13 @@ +{ + "printWidth":120, + "tabWidth":2, + "useTabs":false, + "semi":true, + "singleQuote":false, + "trailingComma":"all", + "bracketSpacing":true, + "arrowParens":"always", + "requirePragma":false, + "insertPragma":false, + "proseWrap":"always" +} diff --git a/package-lock.json b/package-lock.json index 3c2f141..b737bc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.1", "devDependencies": { "electron": "^35.7.5", - "electron-builder": "^24.13.3" + "electron-builder": "^24.13.3", + "prettier": "^3.7.4" } }, "node_modules/@develar/schema-utils": { @@ -675,7 +676,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -830,6 +830,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -849,6 +850,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -871,6 +873,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -886,7 +889,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -894,6 +898,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -995,6 +1000,7 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -1367,6 +1373,7 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -1466,6 +1473,7 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -1479,6 +1487,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -1656,7 +1665,6 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -1841,6 +1849,7 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -1854,6 +1863,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1869,6 +1879,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1882,6 +1893,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2198,7 +2210,8 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fs-extra": { "version": "8.1.0", @@ -2694,7 +2707,8 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -2831,6 +2845,7 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -2844,6 +2859,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -2859,7 +2875,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -2867,6 +2884,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -2883,35 +2901,40 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lowercase-keys": { "version": "2.0.0", @@ -3100,6 +3123,7 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3228,12 +3252,29 @@ "node": ">=10.4.0" } }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/progress": { "version": "2.0.3", @@ -3317,6 +3358,7 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -3332,6 +3374,7 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -3414,7 +3457,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -3610,6 +3654,7 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -3722,6 +3767,7 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -3879,7 +3925,8 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/verror": { "version": "1.10.1", @@ -4030,6 +4077,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -4045,6 +4093,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", diff --git a/package.json b/package.json index 1d7b67e..11b2c24 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,24 @@ "build": { "appId": "dev.kruhlmann.imgfloat", "productName": "Imgfloat", - "files": [ "src/main/node/app.js" ], + "files": [ + "src/main/node/app.js" + ], "asar": false, - "directories": { "output": "dist" }, + "directories": { + "output": "dist" + }, "linux": { - "target": ["AppImage"], + "target": [ + "AppImage" + ], "category": "Utility", "icon": "src/main/resources/assets/icon/raw_icon.png" }, "win": { - "target": ["nsis"], + "target": [ + "nsis" + ], "icon": "src/main/resources/assets/icon/appicon.ico" }, "nsis": { @@ -34,6 +42,7 @@ }, "devDependencies": { "electron": "^35.7.5", - "electron-builder": "^24.13.3" + "electron-builder": "^24.13.3", + "prettier": "^3.7.4" } } diff --git a/src/main/node/app.js b/src/main/node/app.js index 1228e68..0b11c67 100644 --- a/src/main/node/app.js +++ b/src/main/node/app.js @@ -1,36 +1,36 @@ -const { app, BrowserWindow } = require('electron'); -const path = require('path'); +const { app, BrowserWindow } = require("electron"); +const path = require("path"); function createWindow() { - const url = "https://imgfloat.kruhlmann.dev/channels"; - const initialWindowWidthPx = 960; - const initialWindowHeightPx = 640; - const applicationWindow = new BrowserWindow({ - width: initialWindowWidthPx, - height: initialWindowHeightPx, - transparent: true, - frame: true, - backgroundColor: '#00000000', - alwaysOnTop: false, - icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"), - webPreferences: { backgroundThrottling: false }, - }); - applicationWindow.setMenu(null); + const url = "https://imgfloat.kruhlmann.dev/channels"; + const initialWindowWidthPx = 960; + const initialWindowHeightPx = 640; + const applicationWindow = new BrowserWindow({ + width: initialWindowWidthPx, + height: initialWindowHeightPx, + transparent: true, + frame: true, + backgroundColor: "#00000000", + alwaysOnTop: false, + icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"), + webPreferences: { backgroundThrottling: false }, + }); + applicationWindow.setMenu(null); - let canvasSizeInterval; - const clearCanvasSizeInterval = () => { - if (canvasSizeInterval) { - clearInterval(canvasSizeInterval); - canvasSizeInterval = undefined; - } - }; + let canvasSizeInterval; + const clearCanvasSizeInterval = () => { + if (canvasSizeInterval) { + clearInterval(canvasSizeInterval); + canvasSizeInterval = undefined; + } + }; - const lockWindowToCanvas = async () => { - if (applicationWindow.isDestroyed()) { - return false; - } - try { - const size = await applicationWindow.webContents.executeJavaScript(`(() => { + const lockWindowToCanvas = async () => { + if (applicationWindow.isDestroyed()) { + return false; + } + try { + const size = await applicationWindow.webContents.executeJavaScript(`(() => { const canvas = document.getElementById('broadcast-canvas'); if (!canvas || !canvas.width || !canvas.height) { return null; @@ -38,52 +38,52 @@ function createWindow() { return { width: Math.round(canvas.width), height: Math.round(canvas.height) }; })();`); - if (size?.width && size?.height) { - const [currentWidth, currentHeight] = applicationWindow.getSize(); - if (currentWidth !== size.width || currentHeight !== size.height) { - applicationWindow.setSize(size.width, size.height, false); - } - applicationWindow.setMinimumSize(size.width, size.height); - applicationWindow.setMaximumSize(size.width, size.height); - applicationWindow.setResizable(false); - return true; - } - } catch (error) { - // Best-effort sizing; ignore errors from early navigation states. + if (size?.width && size?.height) { + const [currentWidth, currentHeight] = applicationWindow.getSize(); + if (currentWidth !== size.width || currentHeight !== size.height) { + applicationWindow.setSize(size.width, size.height, false); } - return false; - }; + applicationWindow.setMinimumSize(size.width, size.height); + applicationWindow.setMaximumSize(size.width, size.height); + applicationWindow.setResizable(false); + return true; + } + } catch (error) { + // Best-effort sizing; ignore errors from early navigation states. + } + return false; + }; - const handleNavigation = (navigationUrl) => { - try { - const { pathname } = new URL(navigationUrl); - const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname); + const handleNavigation = (navigationUrl) => { + try { + const { pathname } = new URL(navigationUrl); + const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname); - if (isBroadcast) { - clearCanvasSizeInterval(); - canvasSizeInterval = setInterval(lockWindowToCanvas, 750); - lockWindowToCanvas(); - } else { - clearCanvasSizeInterval(); - applicationWindow.setResizable(true); - applicationWindow.setMinimumSize(320, 240); - applicationWindow.setMaximumSize(10000, 10000); - applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false); - } - } catch { - // Ignore malformed URLs while navigating. - } - }; + if (isBroadcast) { + clearCanvasSizeInterval(); + canvasSizeInterval = setInterval(lockWindowToCanvas, 750); + lockWindowToCanvas(); + } else { + clearCanvasSizeInterval(); + applicationWindow.setResizable(true); + applicationWindow.setMinimumSize(320, 240); + applicationWindow.setMaximumSize(10000, 10000); + applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false); + } + } catch { + // Ignore malformed URLs while navigating. + } + }; - applicationWindow.loadURL(url); + applicationWindow.loadURL(url); - applicationWindow.webContents.on('did-finish-load', () => { - handleNavigation(applicationWindow.webContents.getURL()); - }); + applicationWindow.webContents.on("did-finish-load", () => { + handleNavigation(applicationWindow.webContents.getURL()); + }); - applicationWindow.webContents.on('did-navigate', (_event, navigationUrl) => handleNavigation(navigationUrl)); - applicationWindow.webContents.on('did-navigate-in-page', (_event, navigationUrl) => handleNavigation(navigationUrl)); - applicationWindow.on('closed', clearCanvasSizeInterval); + applicationWindow.webContents.on("did-navigate", (_event, navigationUrl) => handleNavigation(navigationUrl)); + applicationWindow.webContents.on("did-navigate-in-page", (_event, navigationUrl) => handleNavigation(navigationUrl)); + applicationWindow.on("closed", clearCanvasSizeInterval); } app.whenReady().then(createWindow); diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index a5fee32..9fd866d 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1,2027 +1,2065 @@ * { - box-sizing: border-box; + box-sizing: border-box; } p { - margin: 0; + margin: 0; } .hidden { - display: none !important; + display: none !important; } body { - font-family: Arial, sans-serif; - background: #0f172a; - color: #e2e8f0; - margin: 0; - padding: 0; + font-family: Arial, sans-serif; + background: #0f172a; + color: #e2e8f0; + margin: 0; + padding: 0; } .landing-body { - min-height: 100vh; - background: radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.18), transparent 30%), - radial-gradient(circle at 80% 0%, rgba(59, 130, 246, 0.16), transparent 25%), - #0f172a; + min-height: 100vh; + background: + radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.18), transparent 30%), + radial-gradient(circle at 80% 0%, rgba(59, 130, 246, 0.16), transparent 25%), #0f172a; } .landing { - max-width: 1100px; - margin: 0 auto; - padding: 40px 20px 64px; + max-width: 1100px; + margin: 0 auto; + padding: 40px 20px 64px; } .landing-meta { - display: flex; - justify-content: flex-end; - margin-top: 18px; + display: flex; + justify-content: flex-end; + margin-top: 18px; } .build-chip { - display: inline-flex; - align-items: center; - gap: 10px; - padding: 6px 10px; - background: rgba(15, 23, 42, 0.7); - border: 1px solid rgba(148, 163, 184, 0.24); - border-radius: 12px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24); + display: inline-flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + background: rgba(15, 23, 42, 0.7); + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24); } .version-badge { - padding: 4px 10px; - border-radius: 999px; - background: linear-gradient(135deg, rgba(124, 58, 237, 0.12), rgba(59, 130, 246, 0.12)); - border: 1px solid rgba(124, 58, 237, 0.35); - color: #cbd5e1; - font-weight: 700; - letter-spacing: 0.2px; - font-size: 13px; + padding: 4px 10px; + border-radius: 999px; + background: linear-gradient(135deg, rgba(124, 58, 237, 0.12), rgba(59, 130, 246, 0.12)); + border: 1px solid rgba(124, 58, 237, 0.35); + color: #cbd5e1; + font-weight: 700; + letter-spacing: 0.2px; + font-size: 13px; } -.channels-body, .settings-body { - min-height: 100vh; - background: radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.16), transparent 30%), - radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 28%), - #0f172a; - display: flex; - align-items: center; - justify-content: center; - padding: clamp(24px, 4vw, 48px); +.channels-body, +.settings-body { + min-height: 100vh; + background: + radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.16), transparent 30%), + radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 28%), #0f172a; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(24px, 4vw, 48px); } -.channels-shell, .settings-shell { - width: min(760px, 100%); - display: flex; - flex-direction: column; - gap: 20px; +.channels-shell, +.settings-shell { + width: min(760px, 100%); + display: flex; + flex-direction: column; + gap: 20px; } -.channels-header, .settings-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; +.channels-header, +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } .cta-row.compact { - margin: 0; + margin: 0; } .settings-main { - display: flex; - justify-content: center; + display: flex; + justify-content: center; } .settings-card { - width: 100%; - background: rgba(11, 18, 32, 0.95); - border: 1px solid #1f2937; - border-radius: 16px; - padding: clamp(20px, 3vw, 32px); - box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); - display: flex; - flex-direction: column; - gap: 10px; + width: 100%; + background: rgba(11, 18, 32, 0.95); + border: 1px solid #1f2937; + border-radius: 16px; + padding: clamp(20px, 3vw, 32px); + box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 10px; } .channels-main { - display: flex; - justify-content: center; + display: flex; + justify-content: center; } .channel-card { - width: 100%; - background: rgba(11, 18, 32, 0.95); - border: 1px solid #1f2937; - border-radius: 16px; - padding: clamp(20px, 3vw, 32px); - box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); - display: flex; - flex-direction: column; - gap: 10px; + width: 100%; + background: rgba(11, 18, 32, 0.95); + border: 1px solid #1f2937; + border-radius: 16px; + padding: clamp(20px, 3vw, 32px); + box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 10px; } .channel-card h1 { - margin: 6px 0 4px; + margin: 6px 0 4px; } .channel-form { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 6px; + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 6px; } .settings-form { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 6px; + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 6px; } .settings-hero { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; - align-items: center; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + align-items: center; } .settings-layout { - display: grid; - grid-template-columns: 2fr minmax(260px, 1fr); - gap: 18px; - align-items: start; + display: grid; + grid-template-columns: 2fr minmax(260px, 1fr); + gap: 18px; + align-items: start; } @media (max-width: 900px) { - .settings-layout { - grid-template-columns: 1fr; - } + .settings-layout { + grid-template-columns: 1fr; + } } .settings-panel { - display: flex; - flex-direction: column; - gap: 18px; + display: flex; + flex-direction: column; + gap: 18px; } .settings-sidebar { - display: flex; - flex-direction: column; - gap: 14px; + display: flex; + flex-direction: column; + gap: 14px; } .hero-copy h1 { - margin: 8px 0 6px; + margin: 8px 0 6px; } .stat-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; } .stat-grid.compact { - gap: 10px; + gap: 10px; } .stat { - border: 1px solid #1f2937; - border-radius: 12px; - padding: 14px; - background: rgba(255, 255, 255, 0.02); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); + border: 1px solid #1f2937; + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.02); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); } .stat-label { - color: #cbd5e1; - font-size: 13px; - margin: 0 0 4px; + color: #cbd5e1; + font-size: 13px; + margin: 0 0 4px; } .stat-value { - font-size: 22px; - font-weight: 700; - margin: 0; + font-size: 22px; + font-weight: 700; + margin: 0; } .stat-subtitle { - margin: 6px 0 0; - color: #94a3b8; - font-size: 12px; + margin: 6px 0 0; + color: #94a3b8; + font-size: 12px; } .field-hint { - color: #94a3b8; - font-size: 13px; - margin: 6px 0 0; + color: #94a3b8; + font-size: 13px; + margin: 6px 0 0; } .form-section { - border: 1px solid #1f2937; - border-radius: 12px; - padding: 14px; - background: rgba(255, 255, 255, 0.01); - display: flex; - flex-direction: column; - gap: 12px; + border: 1px solid #1f2937; + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.01); + display: flex; + flex-direction: column; + gap: 12px; } .form-heading h3 { - margin: 4px 0 0; + margin: 4px 0 0; } .form-heading .muted { - margin-top: 2px; + margin-top: 2px; } .form-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 4px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 4px; } .status-chip { - padding: 8px 12px; - border-radius: 999px; - background: rgba(148, 163, 184, 0.1); - border: 1px solid rgba(148, 163, 184, 0.3); - color: #e2e8f0; - font-size: 14px; - margin: 0; + padding: 8px 12px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.1); + border: 1px solid rgba(148, 163, 184, 0.3); + color: #e2e8f0; + font-size: 14px; + margin: 0; } .status-chip.status-success { - background: rgba(34, 197, 94, 0.12); - border-color: rgba(34, 197, 94, 0.35); - color: #bbf7d0; + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.35); + color: #bbf7d0; } .status-chip.status-warning { - background: rgba(251, 191, 36, 0.12); - border-color: rgba(251, 191, 36, 0.4); - color: #fef3c7; + background: rgba(251, 191, 36, 0.12); + border-color: rgba(251, 191, 36, 0.4); + color: #fef3c7; } .info-card { - display: flex; - flex-direction: column; - gap: 8px; + display: flex; + flex-direction: column; + gap: 8px; } .info-card.subtle { - background: rgba(15, 23, 42, 0.75); + background: rgba(15, 23, 42, 0.75); } .hint-list { - margin: 0; - padding-left: 16px; - color: #cbd5e1; - display: flex; - flex-direction: column; - gap: 6px; + margin: 0; + padding-left: 16px; + color: #cbd5e1; + display: flex; + flex-direction: column; + gap: 6px; } .channels-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 10px; - border-radius: 12px; - border: 1px solid #1f2937; - background: rgba(11, 18, 32, 0.9); + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 10px; + border-radius: 12px; + border: 1px solid #1f2937; + background: rgba(11, 18, 32, 0.9); } .landing-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 32px; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; } .brand { - display: flex; - align-items: center; - gap: 12px; + display: flex; + align-items: center; + gap: 12px; } .brand-mark { - width: 40px; - height: 40px; - border-radius: 12px; - background: linear-gradient(135deg, #7c3aed, #4f46e5); - display: grid; - place-items: center; - font-weight: 700; - letter-spacing: 0.5px; + width: 40px; + height: 40px; + border-radius: 12px; + background: linear-gradient(135deg, #7c3aed, #4f46e5); + display: grid; + place-items: center; + font-weight: 700; + letter-spacing: 0.5px; } .brand-title { - font-weight: 700; - font-size: 18px; + font-weight: 700; + font-size: 18px; } .brand-subtitle { - color: #94a3b8; - font-size: 13px; + color: #94a3b8; + font-size: 13px; } .hero { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 28px; - align-items: center; - background: rgba(15, 23, 42, 0.85); - border: 1px solid #1f2937; - padding: 28px; - border-radius: 16px; - box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 28px; + align-items: center; + background: rgba(15, 23, 42, 0.85); + border: 1px solid #1f2937; + padding: 28px; + border-radius: 16px; + box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); } .hero-compact { - grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); - padding: 24px; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + padding: 24px; } .hero-text h1 { - font-size: 32px; - line-height: 1.2; - margin: 8px 0 12px; + font-size: 32px; + line-height: 1.2; + margin: 8px 0 12px; } .lead { - color: #cbd5e1; - line-height: 1.6; + color: #cbd5e1; + line-height: 1.6; } .pill-list { - list-style: none; - padding: 0; - margin: 16px 0 0; - display: flex; - gap: 10px; - flex-wrap: wrap; + list-style: none; + padding: 0; + margin: 16px 0 0; + display: flex; + gap: 10px; + flex-wrap: wrap; } .pill-list.minimal { - margin-top: 12px; + margin-top: 12px; } .pill-list li { - background: rgba(124, 58, 237, 0.12); - border: 1px solid rgba(124, 58, 237, 0.2); - color: #e9d5ff; - padding: 8px 12px; - border-radius: 999px; - font-weight: 600; - font-size: 14px; + background: rgba(124, 58, 237, 0.12); + border: 1px solid rgba(124, 58, 237, 0.2); + color: #e9d5ff; + padding: 8px 12px; + border-radius: 999px; + font-weight: 600; + font-size: 14px; } .pill-list.minimal li { - background: rgba(124, 58, 237, 0.08); - border-color: rgba(124, 58, 237, 0.18); + background: rgba(124, 58, 237, 0.08); + border-color: rgba(124, 58, 237, 0.18); } .download-section { - margin-top: 26px; - padding: 20px; - background: rgba(11, 18, 32, 0.92); - border-radius: 14px; - border: 1px solid #1f2937; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45); - display: flex; - flex-direction: column; - gap: 14px; + margin-top: 26px; + padding: 20px; + background: rgba(11, 18, 32, 0.92); + border-radius: 14px; + border: 1px solid #1f2937; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 14px; } -.download-header h2, .download-header h3 { - margin: 6px 0 8px; +.download-header h2, +.download-header h3 { + margin: 6px 0 8px; } .version-inline { - font-weight: 700; - color: #e2e8f0; + font-weight: 700; + color: #e2e8f0; } .download-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; } .download-card { - border: 1px solid #1f2937; - border-radius: 12px; - padding: 14px; - background: rgba(15, 23, 42, 0.82); - display: flex; - flex-direction: column; - gap: 10px; - transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; + border: 1px solid #1f2937; + border-radius: 12px; + padding: 14px; + background: rgba(15, 23, 42, 0.82); + display: flex; + flex-direction: column; + gap: 10px; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; } .download-card-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } .download-card--active { - border-color: rgba(124, 58, 237, 0.55); - box-shadow: 0 16px 40px rgba(124, 58, 237, 0.22); - transform: translateY(-2px); + border-color: rgba(124, 58, 237, 0.55); + box-shadow: 0 16px 40px rgba(124, 58, 237, 0.22); + transform: translateY(-2px); } .download-card .button { - margin-top: 4px; + margin-top: 4px; } .download-card-block { - display: flex; - flex-direction: column; - gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; } .eyebrow { - text-transform: uppercase; - letter-spacing: 1px; - color: #a5b4fc; - font-size: 12px; - margin: 0; + text-transform: uppercase; + letter-spacing: 1px; + color: #a5b4fc; + font-size: 12px; + margin: 0; } .eyebrow.subtle { - color: #cbd5e1; + color: #cbd5e1; } .cta-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; - margin: 16px 0 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin: 16px 0 10px; } -.button, button { - background: #7c3aed; - color: white; - padding: 10px 16px; - border: none; - border-radius: 8px; - cursor: pointer; - text-decoration: none; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - font-weight: 600; - box-shadow: 0 10px 30px rgba(124, 58, 237, 0.25); +.button, +button { + background: #7c3aed; + color: white; + padding: 10px 16px; + border: none; + border-radius: 8px; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 600; + box-shadow: 0 10px 30px rgba(124, 58, 237, 0.25); } -.button:disabled, button:disabled, .button[aria-disabled="true"] { - background: #a78bfa; - color: #e5e7eb; - cursor: not-allowed; - box-shadow: none; - opacity: 0.7; +.button:disabled, +button:disabled, +.button[aria-disabled="true"] { + background: #a78bfa; + color: #e5e7eb; + cursor: not-allowed; + box-shadow: none; + opacity: 0.7; } .button:disabled:hover, button:disabled:hover { - transform: none; + transform: none; } .button.block { - width: 100%; + width: 100%; } .broadcaster-button { - background: linear-gradient(115deg, #7c3aed, #2563eb); - border: 1px solid rgba(124, 58, 237, 0.35); - box-shadow: 0 14px 35px rgba(37, 99, 235, 0.3); + background: linear-gradient(115deg, #7c3aed, #2563eb); + border: 1px solid rgba(124, 58, 237, 0.35); + box-shadow: 0 14px 35px rgba(37, 99, 235, 0.3); } .broadcaster-button:hover { - filter: brightness(1.05); - box-shadow: 0 16px 38px rgba(37, 99, 235, 0.38); + filter: brightness(1.05); + box-shadow: 0 16px 38px rgba(37, 99, 235, 0.38); } .block { - width: 100%; - display: flex; - align-items: center; - justify-content: center; + width: 100%; + display: flex; + align-items: center; + justify-content: center; } .button.ghost { - background: transparent; - border: 1px solid #2d3a57; - box-shadow: none; + background: transparent; + border: 1px solid #2d3a57; + box-shadow: none; } .ghost { - background: transparent; - border: 1px solid #2d3a57; - box-shadow: none; + background: transparent; + border: 1px solid #2d3a57; + box-shadow: none; } .secondary { - background: #475569; + background: #475569; } .secondary.danger { - background: #7f1d1d; - border: 1px solid rgba(248, 113, 113, 0.35); - color: #fecdd3; + background: #7f1d1d; + border: 1px solid rgba(248, 113, 113, 0.35); + color: #fecdd3; } .secondary.danger:hover:not(:disabled) { - border-color: rgba(248, 113, 113, 0.6); - background: #991b1b; - color: #fee2e2; + border-color: rgba(248, 113, 113, 0.6); + background: #991b1b; + color: #fee2e2; } .hero-panel { - background: #0b1220; - border: 1px solid #1f2937; - border-radius: 14px; - padding: 18px; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 14px; + padding: 18px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); } .search-panel { - display: flex; - flex-direction: column; - gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; } .text-input { - width: 100%; - padding: 12px; - border-radius: 10px; - border: 1px solid #1f2937; - background: #0f172a; - color: #e2e8f0; - font-size: 15px; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + width: 100%; + padding: 12px; + border-radius: 10px; + border: 1px solid #1f2937; + background: #0f172a; + color: #e2e8f0; + font-size: 15px; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; } .text-input:focus { - outline: none; - border-color: #7c3aed; - box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); + outline: none; + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); } -.text-input:disabled, .text-input[aria-disabled="true"] { - background: #020617; - border-color: #334155; - color: #64748b; - cursor: not-allowed; - box-shadow: none; +.text-input:disabled, +.text-input[aria-disabled="true"] { + background: #020617; + border-color: #334155; + color: #64748b; + cursor: not-allowed; + box-shadow: none; } .text-input:disabled::placeholder { - color: #475569; + color: #475569; } .search-form { - display: flex; - flex-direction: column; - gap: 10px; + display: flex; + flex-direction: column; + gap: 10px; } .search-row { - display: flex; - gap: 10px; - align-items: stretch; + display: flex; + gap: 10px; + align-items: stretch; } .search-row .text-input { - flex: 1; + flex: 1; } .search-row .button { - padding: 0 16px; - height: 46px; + padding: 0 16px; + height: 46px; } .badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: 999px; - background: rgba(124, 58, 237, 0.1); - color: #c4b5fd; - font-weight: 600; - font-size: 12px; - border: 1px solid rgba(124, 58, 237, 0.2); + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(124, 58, 237, 0.1); + color: #c4b5fd; + font-weight: 600; + font-size: 12px; + border: 1px solid rgba(124, 58, 237, 0.2); } .badge.subtle { - background: rgba(148, 163, 184, 0.1); - color: #cbd5e1; - border-color: rgba(148, 163, 184, 0.2); + background: rgba(148, 163, 184, 0.1); + color: #cbd5e1; + border-color: rgba(148, 163, 184, 0.2); } .badge.soft { - background: rgba(59, 130, 246, 0.12); - color: #bfdbfe; - border-color: rgba(59, 130, 246, 0.26); + background: rgba(59, 130, 246, 0.12); + color: #bfdbfe; + border-color: rgba(59, 130, 246, 0.26); } .badge.danger { - background: rgba(248, 113, 113, 0.12); - color: #fecdd3; - border-color: rgba(248, 113, 113, 0.45); + background: rgba(248, 113, 113, 0.12); + color: #fecdd3; + border-color: rgba(248, 113, 113, 0.45); } .badge-row { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; - margin-top: 6px; + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + margin-top: 6px; } .badge-row.stacked { - flex-direction: column; - align-items: flex-start; - gap: 6px; + flex-direction: column; + align-items: flex-start; + gap: 6px; } .preview-summary { - margin: 10px 0 18px; + margin: 10px 0 18px; } .panel-actions { - display: flex; - flex-direction: row; - gap: 20px; - padding: 20px; + display: flex; + flex-direction: row; + gap: 20px; + padding: 20px; } .preview-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; } .preview-card { - background: rgba(255, 255, 255, 0.02); - border: 1px solid #1f2937; - border-radius: 12px; - padding: 12px; - color: #cbd5e1; + background: rgba(255, 255, 255, 0.02); + border: 1px solid #1f2937; + border-radius: 12px; + padding: 12px; + color: #cbd5e1; } .preview-card p { - margin: 8px 0 0; - color: #cbd5e1; - line-height: 1.4; + margin: 8px 0 0; + color: #cbd5e1; + line-height: 1.4; } .landing-footer { - display: flex; - align-items: center; - justify-content: space-between; - background: #0b1220; - padding: 16px; - border: 1px solid #1f2937; - border-radius: 12px; + display: flex; + align-items: center; + justify-content: space-between; + background: #0b1220; + padding: 16px; + border: 1px solid #1f2937; + border-radius: 12px; } .landing-footer.compact { - margin-top: 18px; + margin-top: 18px; } .admin-layout { - min-height: 100vh; - display: flex; - flex-direction: column; + min-height: 100vh; + display: flex; + flex-direction: column; } .admin-body { - background: radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.12), transparent 32%), - radial-gradient(circle at 90% 5%, rgba(124, 58, 237, 0.1), transparent 30%), - #0f172a; + background: + radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.12), transparent 32%), + radial-gradient(circle at 90% 5%, rgba(124, 58, 237, 0.1), transparent 30%), #0f172a; } .admin-frame { - margin: 0 auto; - display: flex; - flex-direction: column; - width: 100%; - height: 100vh; + margin: 0 auto; + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; } .admin-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - padding: 10px 12px; - background: linear-gradient(90deg, rgba(15, 23, 42, 0.95), rgba(12, 20, 34, 0.9)); - border-bottom: 1px solid #1f2937; - border-radius: 0; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 10px 12px; + background: linear-gradient(90deg, rgba(15, 23, 42, 0.95), rgba(12, 20, 34, 0.9)); + border-bottom: 1px solid #1f2937; + border-radius: 0; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); } .topbar-left { - display: flex; - align-items: center; - gap: 10px; + display: flex; + align-items: center; + gap: 10px; } .admin-identity { - display: flex; - gap: 4px; - align-items: flex-start; - flex-direction: column; + display: flex; + gap: 4px; + align-items: flex-start; + flex-direction: column; } .admin-identity h1 { - margin: 2px 0 4px; + margin: 2px 0 4px; } .admin-workspace { - --sidebar-width: 300px; - display: grid; - height: 100%; - grid-template-columns: var(--sidebar-width) 1fr; - gap: 12px; - align-items: stretch; - position: relative; + --sidebar-width: 300px; + display: grid; + height: 100%; + grid-template-columns: var(--sidebar-width) 1fr; + gap: 12px; + align-items: stretch; + position: relative; } .admin-rail { - background: rgba(11, 18, 32, 0.92); - border-right: 1px solid #1f2937; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), 0 18px 45px rgba(0, 0, 0, 0.4); - display: flex; - flex-direction: column; - height: calc(100vh - 81px); + background: rgba(11, 18, 32, 0.92); + border-right: 1px solid #1f2937; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.02), + 0 18px 45px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + height: calc(100vh - 81px); } .rail-header { - display: flex; - align-items: center; - justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; } .rail-body { - display: flex; - flex-direction: column; - gap: 8px; - flex: 1; - min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; } .rail-scroll { - overflow-y: auto; - padding-right: 2px; - flex: 1; - min-height: 0; + overflow-y: auto; + padding-right: 2px; + flex: 1; + min-height: 0; } .canvas-stack { - background: linear-gradient(180deg, rgba(10, 15, 26, 0.95), rgba(12, 17, 30, 0.96)); - border-radius: 16px; - padding: 12px 12px 14px; - box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); - display: flex; - flex-direction: column; - gap: 10px; - min-height: 72vh; + background: linear-gradient(180deg, rgba(10, 15, 26, 0.95), rgba(12, 17, 30, 0.96)); + border-radius: 16px; + padding: 12px 12px 14px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 10px; + min-height: 72vh; } .canvas-surface { - display: flex; - flex-direction: column; - gap: 12px; - height: 100%; + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; } .header-actions.tight { - gap: 8px; + gap: 8px; } .header-actions.horizontal { - flex-direction: row; - align-items: center; - gap: 10px; + flex-direction: row; + align-items: center; + gap: 10px; } .header-actions.horizontal form { - margin: 0; + margin: 0; } .dashboard-body { - min-height: 100vh; - background: radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.14), transparent 30%), - radial-gradient(circle at 90% 10%, rgba(124, 58, 237, 0.12), transparent 28%), - #0f172a; - padding: 36px 18px 64px; + min-height: 100vh; + background: + radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.14), transparent 30%), + radial-gradient(circle at 90% 10%, rgba(124, 58, 237, 0.12), transparent 28%), #0f172a; + padding: 36px 18px 64px; } .dashboard-shell { - max-width: 1100px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: 20px; + max-width: 1100px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 20px; } .dashboard-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 14px 18px; - background: rgba(15, 23, 42, 0.85); - border: 1px solid #1f2937; - border-radius: 14px; - box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + background: rgba(15, 23, 42, 0.85); + border: 1px solid #1f2937; + border-radius: 14px; + box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35); } .user-pill { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; - padding: 10px 12px; - border-radius: 12px; - background: rgba(124, 58, 237, 0.1); - border: 1px solid rgba(124, 58, 237, 0.2); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(124, 58, 237, 0.1); + border: 1px solid rgba(124, 58, 237, 0.2); } .user-display { - font-weight: 700; - color: #e2e8f0; - font-size: 16px; + font-weight: 700; + color: #e2e8f0; + font-size: 16px; } .dashboard-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 20px; - padding: 22px; - background: rgba(15, 23, 42, 0.8); - border: 1px solid #1f2937; - border-radius: 16px; - box-shadow: 0 18px 50px rgba(0,0,0,0.35); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 22px; + background: rgba(15, 23, 42, 0.8); + border: 1px solid #1f2937; + border-radius: 16px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35); } .dashboard-header h1 { - margin: 6px 0 10px; + margin: 6px 0 10px; } .header-actions { - display: flex; - flex-direction: column; - gap: 10px; - align-items: flex-end; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-end; } .chip-row { - display: flex; - gap: 10px; - flex-wrap: wrap; - margin-top: 10px; + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; } .chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border-radius: 999px; - background: rgba(124, 58, 237, 0.15); - color: #c4b5fd; - border: 1px solid rgba(124, 58, 237, 0.25); - font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(124, 58, 237, 0.15); + color: #c4b5fd; + border: 1px solid rgba(124, 58, 237, 0.25); + font-weight: 600; } .chip.subtle { - background: rgba(148, 163, 184, 0.12); - color: #e2e8f0; - border-color: rgba(148, 163, 184, 0.3); + background: rgba(148, 163, 184, 0.12); + color: #e2e8f0; + border-color: rgba(148, 163, 184, 0.3); } .card-grid { - display: grid; - gap: 16px; + display: grid; + gap: 16px; } .card-grid.two-col { - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } .card { - background: #0b1220; - border: 1px solid #1f2937; - border-radius: 14px; - padding: 18px; - box-shadow: 0 14px 40px rgba(0,0,0,0.35); + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 14px; + padding: 18px; + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.35); } .card-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; } .pill { - padding: 6px 10px; - border-radius: 999px; - background: rgba(59, 130, 246, 0.15); - color: #bfdbfe; - border: 1px solid rgba(59, 130, 246, 0.25); - font-weight: 600; - font-size: 13px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(59, 130, 246, 0.15); + color: #bfdbfe; + border: 1px solid rgba(59, 130, 246, 0.25); + font-weight: 600; + font-size: 13px; } .inline-form { - display: flex; - gap: 10px; - flex-wrap: wrap; + display: flex; + gap: 10px; + flex-wrap: wrap; } .inline-form input { - flex: 1; - min-width: 220px; - padding: 10px; - border-radius: 10px; - border: 1px solid #1f2937; - background: #111827; - color: #e2e8f0; + flex: 1; + min-width: 220px; + padding: 10px; + border-radius: 10px; + border: 1px solid #1f2937; + background: #111827; + color: #e2e8f0; } .card-section { - margin-top: 16px; - display: flex; - flex-direction: column; - gap: 8px; + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; } .container { - max-width: 960px; - margin: 40px auto; - background: #111827; - padding: 24px; - border-radius: 12px; - box-shadow: 0 5px 16px rgba(0,0,0,0.3); + max-width: 960px; + margin: 40px auto; + background: #111827; + padding: 24px; + border-radius: 12px; + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.3); } .controls { - display: flex; - gap: 24px; - padding: 16px; - background: #0b1220; - border-radius: 12px; + display: flex; + gap: 24px; + padding: 16px; + background: #0b1220; + border-radius: 12px; } .controls-full { - width: 100%; + width: 100%; } .assets-panel { - background: #0b1220; - padding: 18px; + background: #0b1220; + padding: 18px; } .asset-management { - display: grid; - grid-template-columns: 2fr 356px; - gap: 16px; - align-items: start; + display: grid; + grid-template-columns: 2fr 356px; + gap: 16px; + align-items: start; } .asset-column { - display: flex; - flex-direction: column; - gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; } .asset-column.inspector { - position: sticky; - top: 12px; + position: sticky; + top: 12px; } @media (max-width: 1024px) { - .asset-management { - grid-template-columns: 1fr; - } + .asset-management { + grid-template-columns: 1fr; + } - .asset-column.inspector { - position: static; - } + .asset-column.inspector { + position: static; + } } @media (max-width: 1200px) { - .admin-workspace { - grid-template-columns: 1fr; - } + .admin-workspace { + grid-template-columns: 1fr; + } - .admin-rail { - max-width: 520px; - } + .admin-rail { + max-width: 520px; + } - .rail-inspector { - position: static; - width: 100%; - max-height: none; - } + .rail-inspector { + position: static; + width: 100%; + max-height: none; + } - .overlay { - min-height: 520px; - height: 60vh; - } + .overlay { + min-height: 520px; + height: 60vh; + } } .controls ul { - list-style: none; - padding: 0; - margin-top: 12px; + list-style: none; + padding: 0; + margin-top: 12px; } .controls li { - margin: 6px 0; + margin: 6px 0; } .overlay { - position: relative; - flex: 1; - min-height: clamp(520px, 72vh, 980px); - height: 100%; - background: radial-gradient(circle at 18% 20%, rgba(59, 130, 246, 0.08), transparent 38%), - radial-gradient(circle at 80% 0%, rgba(124, 58, 237, 0.08), transparent 40%), - #0b1220; - overflow: hidden; - border: 1px solid #1f2937; - border-radius: 16px; - box-shadow: 0 16px 45px rgba(0, 0, 0, 0.45); - display: grid; - place-items: center; + position: relative; + flex: 1; + min-height: clamp(520px, 72vh, 980px); + height: 100%; + background: + radial-gradient(circle at 18% 20%, rgba(59, 130, 246, 0.08), transparent 38%), + radial-gradient(circle at 80% 0%, rgba(124, 58, 237, 0.08), transparent 40%), #0b1220; + overflow: hidden; + border: 1px solid #1f2937; + border-radius: 16px; + box-shadow: 0 16px 45px rgba(0, 0, 0, 0.45); + display: grid; + place-items: center; } .overlay canvas { - position: absolute; - top: 0; - left: 0; - pointer-events: auto; - z-index: 2; + position: absolute; + top: 0; + left: 0; + pointer-events: auto; + z-index: 2; } #admin-canvas { - border: 1px solid rgba(148, 163, 184, 0.35); - border-radius: 10px; - box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.5), 0 16px 45px rgba(0, 0, 0, 0.55); - background-color: rgba(255, 0, 255, 0.1); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 10px; + box-shadow: + 0 0 0 1px rgba(15, 23, 42, 0.5), + 0 16px 45px rgba(0, 0, 0, 0.55); + background-color: rgba(255, 0, 255, 0.1); } .broadcast-body canvas { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; } .broadcast-body { - margin: 0; - overflow: hidden; - background: transparent; + margin: 0; + overflow: hidden; + background: transparent; } .panel { - margin-top: 24px; - padding: 16px; - background: #0b1220; - border-radius: 10px; - border: 1px solid #1f2937; + margin-top: 24px; + padding: 16px; + background: #0b1220; + border-radius: 10px; + border: 1px solid #1f2937; } .panel.hidden { - display: none; + display: none; } .panel-title { - margin: 4px 0 0; - font-size: 18px; - color: #e5e7eb; + margin: 4px 0 0; + font-size: 18px; + color: #e5e7eb; } .panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; } .canvas-panel { - padding: 18px 18px 20px; - margin-top: 0; + padding: 18px 18px 20px; + margin-top: 0; } .canvas-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; } .canvas-meta { - display: flex; - gap: 8px; - align-items: center; + display: flex; + gap: 8px; + align-items: center; } .canvas-stage { - margin-top: 14px; - display: flex; - flex-direction: column; - gap: 10px; + margin-top: 14px; + display: flex; + flex-direction: column; + gap: 10px; } .canvas-boundary { - background-image: linear-gradient(to right, rgba(255,255,255,0.03) 1px, transparent 1px), - linear-gradient(to bottom, rgba(255,255,255,0.03) 1px, transparent 1px); - background-size: 24px 24px; - position: relative; - isolation: isolate; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + background-size: 24px 24px; + position: relative; + isolation: isolate; } .canvas-boundary::after { - content: ""; - position: absolute; - border-radius: 12px; - pointer-events: none; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02), 0 16px 40px rgba(0, 0, 0, 0.35); + content: ""; + position: absolute; + border-radius: 12px; + pointer-events: none; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 16px 40px rgba(0, 0, 0, 0.35); } .canvas-guides { - position: absolute; - inset: 0; - pointer-events: none; + position: absolute; + inset: 0; + pointer-events: none; } .canvas-footnote { - color: #94a3b8; - font-size: 13px; - margin: 0 4px; + color: #94a3b8; + font-size: 13px; + margin: 0 4px; } .rail-inspector { - margin-top: 0; - display: flex; - flex-direction: column; - gap: 12px; - max-height: none; - overflow: visible; - border-top: 1px solid #1f2937; - padding: 14px 16px 0; - width: 100%; + margin-top: 0; + display: flex; + flex-direction: column; + gap: 12px; + max-height: none; + overflow: visible; + border-top: 1px solid #1f2937; + padding: 14px 16px 0; + width: 100%; } .rail-inspector .panel-section { - margin-top: 0; - overflow: visible; - padding-right: 0; - max-height: none; + margin-top: 0; + overflow: visible; + padding-right: 0; + max-height: none; } .control-panel { - margin-top: 0; + margin-top: 0; } .asset-settings { - background: transparent; - box-shadow: none; - padding: 0; - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; + background: transparent; + box-shadow: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; } .panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; } .panel-header h4 { - margin: 0; + margin: 0; } .panel-summary { - background: rgba(124, 58, 237, 0.06); - border: 1px solid rgba(124, 58, 237, 0.22); - border-radius: 12px; - padding: 12px 14px; - max-width: 320px; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + background: rgba(124, 58, 237, 0.06); + border: 1px solid rgba(124, 58, 237, 0.22); + border-radius: 12px; + padding: 12px 14px; + max-width: 320px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); } .panel-summary p { - margin: 0; + margin: 0; } .asset-inspector { - background: transparent; - border: none; - border-radius: 0; - padding: 0; - width: 100%; + background: transparent; + border: none; + border-radius: 0; + padding: 0; + width: 100%; } .asset-controls-placeholder { - margin-top: 0; + margin-top: 0; } .selected-asset-banner { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - padding: 4px 0 10px; - border-radius: 0; - background: transparent; - border: none; - box-shadow: none; + display: grid; + grid-template-columns: 1fr; + gap: 10px; + padding: 4px 0 10px; + border-radius: 0; + background: transparent; + border: none; + box-shadow: none; } .selected-asset-main { - display: flex; - flex-direction: column; - gap: 6px; - width: 100%; - min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + min-width: 0; } .selected-asset-actions { - display: flex; - align-items: center; - gap: 8px; + display: flex; + align-items: center; + gap: 8px; } .panel ul { - list-style: none; - padding: 0; - margin: 8px 0 0 0; + list-style: none; + padding: 0; + margin: 8px 0 0 0; } .panel li { - margin: 6px 0; + margin: 6px 0; } .upload-row { - display: flex; - align-items: center; + display: flex; + align-items: center; } .file-input-field { - display: none; + display: none; } .file-input-trigger { - flex: 1; - display: inline-flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - background: rgba(124, 58, 237, 0.08); - cursor: pointer; - transition: background 120ms ease, background 120ms ease; + flex: 1; + display: inline-flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: rgba(124, 58, 237, 0.08); + cursor: pointer; + transition: + background 120ms ease, + background 120ms ease; } .file-input-trigger:hover { - background: rgba(124, 58, 237, 0.14); + background: rgba(124, 58, 237, 0.14); } .file-input-icon { - width: 38px; - height: 38px; - border-radius: 8px; - display: grid; - place-items: center; - background: rgba(124, 58, 237, 0.16); - color: #c4b5fd; + width: 38px; + height: 38px; + border-radius: 8px; + display: grid; + place-items: center; + background: rgba(124, 58, 237, 0.16); + color: #c4b5fd; } .file-input-copy { - display: flex; - flex-direction: column; - gap: 4px; + display: flex; + flex-direction: column; + gap: 4px; } .file-input-copy strong { - font-size: 14px; + font-size: 14px; } .file-input-copy small { - color: #cbd5e1; + color: #cbd5e1; } .title-row { - display: flex; - align-items: baseline; - gap: 8px; - flex-wrap: nowrap; - justify-content: space-between; + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: nowrap; + justify-content: space-between; } .title-row strong { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .asset-resolution { - margin-left: auto; - white-space: nowrap; - font-size: 12px; - color: #cbd5e1; - text-align: right; + margin-left: auto; + white-space: nowrap; + font-size: 12px; + color: #cbd5e1; + text-align: right; } .property-list { - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; } .property-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; } .property-label { - color: #e2e8f0; - font-weight: 600; - font-size: 14px; + color: #e2e8f0; + font-weight: 600; + font-size: 14px; } .property-control { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; } .property-row .number-input { - max-width: 140px; - text-align: right; + max-width: 140px; + text-align: right; } .property-row .range-input { - min-width: 200px; - flex: 1; + min-width: 200px; + flex: 1; } .property-row .badge-row { - justify-content: flex-end; + justify-content: flex-end; } .inline-toggle { - padding-top: 0; + padding-top: 0; } .meta-text { - margin: 6px 0 0; + margin: 6px 0 0; } .subtle-text { - color: #94a3b8; - font-size: 12px; + color: #94a3b8; + font-size: 12px; } .panel-section { - margin-top: 12px; - padding: 14px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(148, 163, 184, 0.15); + margin-top: 12px; + padding: 14px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(148, 163, 184, 0.15); } .panel-section.two-col { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 14px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; } .section-header { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 10px; + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; } .section-header h5 { - margin: 0; + margin: 0; } .field-note { - margin: 0; - color: #94a3b8; - font-size: 12px; + margin: 0; + color: #94a3b8; + font-size: 12px; } .stacked-field { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 10px; + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; } .label-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } .value-hint { - color: #cbd5e1; - font-size: 12px; + color: #cbd5e1; + font-size: 12px; } .asset-list { - display: flex; - flex-direction: column; - padding: 0; - margin: 0; + display: flex; + flex-direction: column; + padding: 0; + margin: 0; } .asset-item { - display: flex; - flex-direction: column; - align-items: stretch; - padding: 8px 10px; - background: #111827; - border-top: 1px solid #1f2937; - cursor: pointer; - gap: 8px; - font-size: 13px; - line-height: 1.35; - height: 60px; + display: flex; + flex-direction: column; + align-items: stretch; + padding: 8px 10px; + background: #111827; + border-top: 1px solid #1f2937; + cursor: pointer; + gap: 8px; + font-size: 13px; + line-height: 1.35; + height: 60px; } .asset-item:last-child { - border-bottom: 1px solid #1f2937; + border-bottom: 1px solid #1f2937; } .asset-item strong { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .asset-item.selected { - background: #235; + background: #235; } .asset-item.pending { - cursor: default; - opacity: 0.92; + cursor: default; + opacity: 0.92; } .asset-item.is-hidden { - opacity: 0.72; - border-style: dashed; + opacity: 0.72; + border-style: dashed; } .asset-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } .asset-row .meta { - flex: 1; + flex: 1; } .asset-item .meta { - display: flex; - flex-direction: column; - gap: 4px; + display: flex; + flex-direction: column; + gap: 4px; } .asset-item small { - color: #94a3b8; + color: #94a3b8; } .asset-item .actions { - display: flex; - gap: 6px; + display: flex; + gap: 6px; } .asset-inspector .selected-asset-actions .icon-button, .asset-inspector .selected-asset-actions .icon-button:disabled { - background: #0f172a; + background: #0f172a; } .asset-inspector .panel-section { - margin-top: 0; - padding: 0; - border-radius: 0; - background: transparent; - border: none; + margin-top: 0; + padding: 0; + border-radius: 0; + background: transparent; + border: none; } .asset-meta-badges { - margin-top: 4px; + margin-top: 4px; } .asset-detail { - margin-top: 4px; + margin-top: 4px; } .icon-button { - display: inline-flex; - align-items: center; - text-decoration: none; - text-align: center; - justify-content: center; - gap: 6px; - padding: 8px 10px; - width: 34px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.25); - background: rgba(255, 255, 255, 0.04); - color: #e2e8f0; - transition: all 0.15s ease; + display: inline-flex; + align-items: center; + text-decoration: none; + text-align: center; + justify-content: center; + gap: 6px; + padding: 8px 10px; + width: 34px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(255, 255, 255, 0.04); + color: #e2e8f0; + transition: all 0.15s ease; } .icon-button .icon { - font-size: 16px; - line-height: 1; + font-size: 16px; + line-height: 1; } .icon-button:hover { - border-color: rgba(124, 58, 237, 0.4); - box-shadow: 0 5px 18px rgba(0, 0, 0, 0.25); + border-color: rgba(124, 58, 237, 0.4); + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.25); } .icon-button.danger { - border-color: rgba(248, 113, 113, 0.35); - color: #fecdd3; + border-color: rgba(248, 113, 113, 0.35); + color: #fecdd3; } .icon-button.danger:hover { - border-color: rgba(248, 113, 113, 0.6); - background: rgba(248, 113, 113, 0.08); + border-color: rgba(248, 113, 113, 0.6); + background: rgba(248, 113, 113, 0.08); } .asset-item.hidden { - opacity: 0.6; + opacity: 0.6; } .asset-preview { - width: 38px; - height: 38px; - object-fit: contain; - background: #0b1220; - border: 1px solid #1f2937; - border-radius: 8px; - flex-shrink: 0; + width: 38px; + height: 38px; + object-fit: contain; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 8px; + flex-shrink: 0; } .asset-preview.still { - position: relative; - overflow: hidden; - background-size: cover; - background-position: center; - background-repeat: no-repeat; - object-fit: cover; + position: relative; + overflow: hidden; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + object-fit: cover; } .asset-preview.still:not(.has-image) { - display: grid; - place-items: center; - color: #cbd5e1; - background: #111827; + display: grid; + place-items: center; + color: #cbd5e1; + background: #111827; } .preview-overlay { - position: absolute; - inset: 0; - display: grid; - place-items: center; - background: linear-gradient(180deg, rgba(15, 23, 42, 0.4), rgba(15, 23, 42, 0.4)); - color: #e5e7eb; - pointer-events: none; - font-size: 18px; + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.4), rgba(15, 23, 42, 0.4)); + color: #e5e7eb; + pointer-events: none; + font-size: 18px; } .pending-preview { - display: grid; - place-items: center; - color: #cbd5e1; - background: rgba(124, 58, 237, 0.08); - border-style: dashed; + display: grid; + place-items: center; + color: #cbd5e1; + background: rgba(124, 58, 237, 0.08); + border-style: dashed; } .upload-progress { - margin-top: 10px; - width: 100%; - height: 6px; - background: rgba(148, 163, 184, 0.12); - border: 1px solid rgba(148, 163, 184, 0.25); - border-radius: 999px; - overflow: hidden; + margin-top: 10px; + width: 100%; + height: 6px; + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 999px; + overflow: hidden; } .upload-progress-bar { - width: 100%; - height: 100%; - background: linear-gradient(90deg, rgba(124, 58, 237, 0.8), rgba(99, 102, 241, 0.8), rgba(124, 58, 237, 0.8)); - background-size: 200% 100%; - animation: upload-progress 1.2s linear infinite; + width: 100%; + height: 100%; + background: linear-gradient(90deg, rgba(124, 58, 237, 0.8), rgba(99, 102, 241, 0.8), rgba(124, 58, 237, 0.8)); + background-size: 200% 100%; + animation: upload-progress 1.2s linear infinite; } .upload-progress-bar.is-processing { - background: linear-gradient(90deg, rgba(34, 197, 94, 0.85), rgba(52, 211, 153, 0.8), rgba(34, 197, 94, 0.85)); - animation-duration: 1.8s; + background: linear-gradient(90deg, rgba(34, 197, 94, 0.85), rgba(52, 211, 153, 0.8), rgba(34, 197, 94, 0.85)); + animation-duration: 1.8s; } @keyframes upload-progress { - from { - background-position: 200% 0; - } - to { - background-position: -200% 0; - } + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } } .audio-icon { - display: flex; - align-items: center; - justify-content: center; - font-size: 28px; - color: #fbbf24; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + color: #fbbf24; } .sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; } .control-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin-top: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-top: 12px; } .control-grid.condensed { - gap: 10px; - margin-top: 8px; + gap: 10px; + margin-top: 8px; } .control-grid.split-row { - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - margin-top: 6px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + margin-top: 6px; } .control-grid.three-col { - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } .control-grid label { - display: flex; - flex-direction: column; - gap: 6px; - color: #cbd5e1; + display: flex; + flex-direction: column; + gap: 6px; + color: #cbd5e1; } .control-grid .inline-toggle { - align-items: center; - justify-content: space-between; + align-items: center; + justify-content: space-between; } .control-grid input[type="number"], .control-grid input[type="range"] { - padding: 8px; - border-radius: 6px; - border: 1px solid #1f2937; - background: #0f172a; - color: #e2e8f0; + padding: 8px; + border-radius: 6px; + border: 1px solid #1f2937; + background: #0f172a; + color: #e2e8f0; } .range-meta { - display: flex; - justify-content: space-between; - color: #94a3b8; - font-size: 12px; - margin-top: -6px; - padding: 0 2px; + display: flex; + justify-content: space-between; + color: #94a3b8; + font-size: 12px; + margin-top: -6px; + padding: 0 2px; } .number-input { - position: relative; - padding-right: 48px !important; - font-variant-numeric: tabular-nums; + position: relative; + padding-right: 48px !important; + font-variant-numeric: tabular-nums; } .number-input::-webkit-outer-spin-button, .number-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; + -webkit-appearance: none; + margin: 0; } .number-input { - -moz-appearance: textfield; + -moz-appearance: textfield; } .number-input:focus { - border-color: #7c3aed; - box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); } .control-actions { - display: flex; - gap: 8px; - margin-top: 12px; - flex-wrap: wrap; - width: 100%; + display: flex; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; + width: 100%; } .control-actions.compact button { - padding: 10px 12px; + padding: 10px 12px; } .control-actions.filled { - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(124, 58, 237, 0.22); - padding: 12px; - border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(124, 58, 237, 0.22); + padding: 12px; + border-radius: 12px; } .unified-actions { - padding: 0; - margin: 14px 0; + padding: 0; + margin: 14px 0; } .unified-actions button { - flex: 1 1 48px; + flex: 1 1 48px; } .checkbox-inline { - display: flex; - align-items: center; - gap: 8px; - padding-top: 6px; - position: relative; + display: flex; + align-items: center; + gap: 8px; + padding-top: 6px; + position: relative; } .checkbox-inline.toggle { - gap: 12px; - cursor: pointer; - user-select: none; + gap: 12px; + cursor: pointer; + user-select: none; } .checkbox-inline input[type="checkbox"] { - position: absolute; - opacity: 0; - width: 1px; - height: 1px; + position: absolute; + opacity: 0; + width: 1px; + height: 1px; } .toggle-track { - position: relative; - align-self: flex-start; - width: 52px; - height: 25px; - display: inline-flex; - align-items: center; - padding: 4px; - border-radius: 999px; - background: linear-gradient(180deg, #e2e8f0, #cbd5e1); - border: 1px solid #cbd5e1; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); - transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; + position: relative; + align-self: flex-start; + width: 52px; + height: 25px; + display: inline-flex; + align-items: center; + padding: 4px; + border-radius: 999px; + background: linear-gradient(180deg, #e2e8f0, #cbd5e1); + border: 1px solid #cbd5e1; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); + transition: + background 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; } .toggle-thumb { - width: 17px; - height: 17px; - border-radius: 999px; - background: #ffffff; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18), 0 1px 0 rgba(255, 255, 255, 0.6) inset; - transform: translateX(0); - transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease; + width: 17px; + height: 17px; + border-radius: 999px; + background: #ffffff; + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.18), + 0 1px 0 rgba(255, 255, 255, 0.6) inset; + transform: translateX(0); + transition: + transform 160ms ease, + box-shadow 160ms ease, + background 160ms ease; } .checkbox-inline input[type="checkbox"]:checked + .toggle-track { - background: linear-gradient(180deg, #7c3aed, #342366); - border-color: #7c3aed; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(52, 199, 89, 0.35); + background: linear-gradient(180deg, #7c3aed, #342366); + border-color: #7c3aed; + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.12), + 0 0 0 1px rgba(52, 199, 89, 0.35); } .checkbox-inline input[type="checkbox"]:checked + .toggle-track .toggle-thumb { - transform: translateX(25px); - box-shadow: 0 2px 6px rgba(40, 183, 75, 0.35), 0 1px 0 rgba(255, 255, 255, 0.6) inset; + transform: translateX(25px); + box-shadow: + 0 2px 6px rgba(40, 183, 75, 0.35), + 0 1px 0 rgba(255, 255, 255, 0.6) inset; } .checkbox-inline input[type="checkbox"]:focus-visible + .toggle-track { - box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.35), inset 0 1px 2px rgba(0, 0, 0, 0.18); + box-shadow: + 0 0 0 3px rgba(124, 58, 237, 0.35), + inset 0 1px 2px rgba(0, 0, 0, 0.18); } .toggle-label { - color: #e2e8f0; - font-weight: 600; + color: #e2e8f0; + font-weight: 600; } .muted { - color: #94a3b8; - font-size: 0.9em; + color: #94a3b8; + font-size: 0.9em; } .tiny { - font-size: 13px; + font-size: 13px; } .range-value { - color: #a5b4fc; - font-size: 12px; - margin-top: -4px; + color: #a5b4fc; + font-size: 12px; + margin-top: -4px; } .landing-footer .muted { - font-size: 12px; + font-size: 12px; } .stacked-list { - list-style: none; - padding: 0; - margin: 12px 0 0; - display: flex; - flex-direction: column; - gap: 10px; + list-style: none; + padding: 0; + margin: 12px 0 0; + display: flex; + flex-direction: column; + gap: 10px; } #admin-suggestions { - max-height: 240px; - overflow-y: auto; - padding-right: 6px; + max-height: 240px; + overflow-y: auto; + padding-right: 6px; } .stacked-list-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 14px; - border-radius: 12px; - background: #111827; - border: 1px solid #1f2937; + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + border-radius: 12px; + background: #111827; + border: 1px solid #1f2937; } .stacked-list-item .list-title { - margin: 0; - font-weight: 700; + margin: 0; + font-weight: 700; } .identity-row { - display: flex; - align-items: center; - gap: 12px; + display: flex; + align-items: center; + gap: 12px; } .identity-text { - display: flex; - flex-direction: column; - gap: 2px; + display: flex; + flex-direction: column; + gap: 2px; } .avatar { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; - background: linear-gradient(135deg, #7c3aed, #4f46e5); - display: grid; - place-items: center; - font-weight: 700; - color: #e0e7ff; - text-transform: uppercase; + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + background: linear-gradient(135deg, #7c3aed, #4f46e5); + display: grid; + place-items: center; + font-weight: 700; + color: #e0e7ff; + text-transform: uppercase; } .avatar-fallback { - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.08); } .toast-container { - position: fixed; - bottom: 16px; - right: 16px; - display: flex; - flex-direction: column; - gap: 12px; - z-index: 10000; - max-width: 360px; + position: fixed; + bottom: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 12px; + z-index: 10000; + max-width: 360px; } .toast { - display: grid; - grid-template-columns: auto 1fr; - gap: 12px; - align-items: center; - padding: 12px 14px; - border-radius: 12px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); - border: 1px solid rgba(255, 255, 255, 0.08); - background: #0b1221; - color: #e5e7eb; - cursor: pointer; - transition: transform 120ms ease, opacity 120ms ease; + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.08); + background: #0b1221; + color: #e5e7eb; + cursor: pointer; + transition: + transform 120ms ease, + opacity 120ms ease; } .toast:hover { - transform: translateY(-2px); + transform: translateY(-2px); } .toast-exit { - opacity: 0; - transform: translateY(-6px); + opacity: 0; + transform: translateY(-6px); } .toast-indicator { - width: 12px; - height: 12px; - border-radius: 50%; - background: #a5b4fc; - box-shadow: 0 0 0 4px rgba(165, 180, 252, 0.16); + width: 12px; + height: 12px; + border-radius: 50%; + background: #a5b4fc; + box-shadow: 0 0 0 4px rgba(165, 180, 252, 0.16); } .toast-message { - margin: 0; - font-size: 14px; - line-height: 1.4; + margin: 0; + font-size: 14px; + line-height: 1.4; } .toast-success { - border-color: rgba(34, 197, 94, 0.35); - background: rgba(16, 185, 129, 0.42); + border-color: rgba(34, 197, 94, 0.35); + background: rgba(16, 185, 129, 0.42); } .toast-success .toast-indicator { - background: #34d399; - box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.2); + background: #34d399; + box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.2); } .toast-error { - border-color: rgba(239, 68, 68, 0.35); - background: rgba(248, 113, 113, 0.42); + border-color: rgba(239, 68, 68, 0.35); + background: rgba(248, 113, 113, 0.42); } .toast-error .toast-indicator { - background: #f87171; - box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.2); + background: #f87171; + box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.2); } .toast-warning { - border-color: rgba(251, 191, 36, 0.35); - background: rgba(251, 191, 36, 0.42); + border-color: rgba(251, 191, 36, 0.35); + background: rgba(251, 191, 36, 0.42); } .toast-warning .toast-indicator { - background: #facc15; - box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.2); + background: #facc15; + box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.2); } .toast-info { - border-color: rgba(96, 165, 250, 0.35); - background: rgba(96, 165, 250, 0.12); + border-color: rgba(96, 165, 250, 0.35); + background: rgba(96, 165, 250, 0.12); } .toast-info .toast-indicator { - background: #60a5fa; - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); + background: #60a5fa; + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); } diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index d5a5fef..048ab12 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -1,6 +1,6 @@ -const canvas = document.getElementById('admin-canvas'); -const ctx = canvas.getContext('2d'); -const overlay = document.getElementById('admin-overlay'); +const canvas = document.getElementById("admin-canvas"); +const ctx = canvas.getContext("2d"); +const overlay = document.getElementById("admin-overlay"); let canvasSettings = { width: 1920, height: 1080 }; canvas.width = canvasSettings.width; canvas.height = canvasSettings.height; @@ -20,43 +20,43 @@ const VOLUME_SLIDER_MAX = SETTINGS.maxAssetVolumeFraction * 100; const VOLUME_CURVE_STRENGTH = -0.6; const KEYBOARD_NUDGE_STEP = 5; const KEYBOARD_NUDGE_FAST_STEP = 20; -const controlsPanel = document.getElementById('asset-controls'); -const widthInput = document.getElementById('asset-width'); -const heightInput = document.getElementById('asset-height'); -const aspectLockInput = document.getElementById('maintain-aspect'); -const speedInput = document.getElementById('asset-speed'); -const speedLabel = document.getElementById('asset-speed-label'); -const volumeInput = document.getElementById('asset-volume'); -const volumeLabel = document.getElementById('asset-volume-label'); -const selectedZLabel = document.getElementById('asset-z-level'); -const playbackSection = document.getElementById('playback-section'); -const volumeSection = document.getElementById('volume-section'); -const audioSection = document.getElementById('audio-section'); -const layoutSection = document.getElementById('layout-section'); -const audioLoopInput = document.getElementById('asset-audio-loop'); -const audioDelayInput = document.getElementById('asset-audio-delay'); -const audioSpeedInput = document.getElementById('asset-audio-speed'); -const audioSpeedLabel = document.getElementById('asset-audio-speed-label'); -const audioPitchInput = document.getElementById('asset-audio-pitch'); -const audioDelayLabel = document.getElementById('asset-audio-delay-label'); -const audioPitchLabel = document.getElementById('asset-audio-pitch-label'); -const controlsPlaceholder = document.getElementById('asset-controls-placeholder'); -const fileNameLabel = document.getElementById('asset-file-name'); -const assetInspector = document.getElementById('asset-inspector'); -const selectedAssetName = document.getElementById('selected-asset-name'); -const selectedAssetMeta = document.getElementById('selected-asset-meta'); -const selectedAssetResolution = document.getElementById('selected-asset-resolution'); -const selectedAssetIdLabel = document.getElementById('selected-asset-id'); -const selectedAssetBadges = document.getElementById('selected-asset-badges'); -const selectedVisibilityBtn = document.getElementById('selected-asset-visibility'); -const selectedDeleteBtn = document.getElementById('selected-asset-delete'); -const assetActionRow = document.getElementById('asset-actions'); -const assetActionButtons = Array.from(assetActionRow?.querySelectorAll('button') ?? []); -const canvasResolutionLabel = document.getElementById('canvas-resolution'); -const canvasScaleLabel = document.getElementById('canvas-scale'); +const controlsPanel = document.getElementById("asset-controls"); +const widthInput = document.getElementById("asset-width"); +const heightInput = document.getElementById("asset-height"); +const aspectLockInput = document.getElementById("maintain-aspect"); +const speedInput = document.getElementById("asset-speed"); +const speedLabel = document.getElementById("asset-speed-label"); +const volumeInput = document.getElementById("asset-volume"); +const volumeLabel = document.getElementById("asset-volume-label"); +const selectedZLabel = document.getElementById("asset-z-level"); +const playbackSection = document.getElementById("playback-section"); +const volumeSection = document.getElementById("volume-section"); +const audioSection = document.getElementById("audio-section"); +const layoutSection = document.getElementById("layout-section"); +const audioLoopInput = document.getElementById("asset-audio-loop"); +const audioDelayInput = document.getElementById("asset-audio-delay"); +const audioSpeedInput = document.getElementById("asset-audio-speed"); +const audioSpeedLabel = document.getElementById("asset-audio-speed-label"); +const audioPitchInput = document.getElementById("asset-audio-pitch"); +const audioDelayLabel = document.getElementById("asset-audio-delay-label"); +const audioPitchLabel = document.getElementById("asset-audio-pitch-label"); +const controlsPlaceholder = document.getElementById("asset-controls-placeholder"); +const fileNameLabel = document.getElementById("asset-file-name"); +const assetInspector = document.getElementById("asset-inspector"); +const selectedAssetName = document.getElementById("selected-asset-name"); +const selectedAssetMeta = document.getElementById("selected-asset-meta"); +const selectedAssetResolution = document.getElementById("selected-asset-resolution"); +const selectedAssetIdLabel = document.getElementById("selected-asset-id"); +const selectedAssetBadges = document.getElementById("selected-asset-badges"); +const selectedVisibilityBtn = document.getElementById("selected-asset-visibility"); +const selectedDeleteBtn = document.getElementById("selected-asset-delete"); +const assetActionRow = document.getElementById("asset-actions"); +const assetActionButtons = Array.from(assetActionRow?.querySelectorAll("button") ?? []); +const canvasResolutionLabel = document.getElementById("canvas-resolution"); +const canvasScaleLabel = document.getElementById("canvas-scale"); const aspectLockState = new Map(); const commitSizeChange = debounce(() => applyTransformFromInputs(), 180); -const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart']; +const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"]; let drawPending = false; let layerOrder = []; @@ -67,2160 +67,2200 @@ let lastSizeInputChanged = null; let stompClient; audioUnlockEvents.forEach((eventName) => { - window.addEventListener(eventName, () => { - if (!pendingAudioUnlock.size) return; - pendingAudioUnlock.forEach((controller) => { - safePlay(controller); - }); - pendingAudioUnlock.clear(); + window.addEventListener(eventName, () => { + if (!pendingAudioUnlock.size) return; + pendingAudioUnlock.forEach((controller) => { + safePlay(controller); }); + pendingAudioUnlock.clear(); + }); }); function debounce(fn, wait = 150) { - let timeout; - return (...args) => { - clearTimeout(timeout); - timeout = setTimeout(() => fn(...args), wait); - }; + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), wait); + }; } function isFormInputElement(element) { - if (!element) return false; - if (element.isContentEditable) return true; - const tag = element.tagName ? element.tagName.toLowerCase() : ''; - return ['input', 'textarea', 'select', 'button', 'option'].includes(tag); + if (!element) return false; + if (element.isContentEditable) return true; + const tag = element.tagName ? element.tagName.toLowerCase() : ""; + return ["input", "textarea", "select", "button", "option"].includes(tag); } function schedulePersistTransform(asset, silent = false, delay = 200) { - if (!asset?.id) return; - cancelPendingTransform(asset.id); - const timeout = setTimeout(() => { - pendingTransformSaves.delete(asset.id); - persistTransform(asset, silent); - }, delay); - pendingTransformSaves.set(asset.id, timeout); + if (!asset?.id) return; + cancelPendingTransform(asset.id); + const timeout = setTimeout(() => { + pendingTransformSaves.delete(asset.id); + persistTransform(asset, silent); + }, delay); + pendingTransformSaves.set(asset.id, timeout); } function cancelPendingTransform(assetId) { - const pending = pendingTransformSaves.get(assetId); - if (pending) { - clearTimeout(pending); - pendingTransformSaves.delete(assetId); - } + const pending = pendingTransformSaves.get(assetId); + if (pending) { + clearTimeout(pending); + pendingTransformSaves.delete(assetId); + } } -function ensureLayerPosition(assetId, placement = 'keep') { - const asset = assets.get(assetId); - if (asset && isAudioAsset(asset)) { - return; - } - const existingIndex = layerOrder.indexOf(assetId); - if (existingIndex !== -1 && placement === 'keep') { - return; - } - if (existingIndex !== -1) { - layerOrder.splice(existingIndex, 1); - } - if (placement === 'append') { - layerOrder.push(assetId); - } else { - layerOrder.unshift(assetId); - } - layerOrder = layerOrder.filter((id) => assets.has(id)); +function ensureLayerPosition(assetId, placement = "keep") { + const asset = assets.get(assetId); + if (asset && isAudioAsset(asset)) { + return; + } + const existingIndex = layerOrder.indexOf(assetId); + if (existingIndex !== -1 && placement === "keep") { + return; + } + if (existingIndex !== -1) { + layerOrder.splice(existingIndex, 1); + } + if (placement === "append") { + layerOrder.push(assetId); + } else { + layerOrder.unshift(assetId); + } + layerOrder = layerOrder.filter((id) => assets.has(id)); } function getLayerOrder() { - layerOrder = layerOrder.filter((id) => { - const asset = assets.get(id); - return asset && !isAudioAsset(asset); - }); - assets.forEach((asset, id) => { - if (isAudioAsset(asset)) { - return; - } - if (!layerOrder.includes(id)) { - layerOrder.unshift(id); - } - }); - return layerOrder; + layerOrder = layerOrder.filter((id) => { + const asset = assets.get(id); + return asset && !isAudioAsset(asset); + }); + assets.forEach((asset, id) => { + if (isAudioAsset(asset)) { + return; + } + if (!layerOrder.includes(id)) { + layerOrder.unshift(id); + } + }); + return layerOrder; } function getAssetsByLayer() { - return getLayerOrder().map((id) => assets.get(id)).filter(Boolean); + return getLayerOrder() + .map((id) => assets.get(id)) + .filter(Boolean); } function getAudioAssets() { - return Array.from(assets.values()) - .filter((asset) => isAudioAsset(asset)) - .sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0)); + return Array.from(assets.values()) + .filter((asset) => isAudioAsset(asset)) + .sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0)); } function getRenderOrder() { - return [...getLayerOrder()].reverse().map((id) => assets.get(id)).filter(Boolean); + return [...getLayerOrder()] + .reverse() + .map((id) => assets.get(id)) + .filter(Boolean); } function getLayerValue(assetId) { - const asset = assets.get(assetId); - if (asset && isAudioAsset(asset)) { - return 0; - } - const order = getLayerOrder(); - const index = order.indexOf(assetId); - if (index === -1) return 1; - return order.length - index; + const asset = assets.get(assetId); + if (asset && isAudioAsset(asset)) { + return 0; + } + const order = getLayerOrder(); + const index = order.indexOf(assetId); + if (index === -1) return 1; + return order.length - index; } function addPendingUpload(name) { - const pending = { - id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`, - name, - status: 'uploading', - createdAtMs: Date.now() - }; - pendingUploads.push(pending); - renderAssetList(); - return pending.id; + const pending = { + id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`, + name, + status: "uploading", + createdAtMs: Date.now(), + }; + pendingUploads.push(pending); + renderAssetList(); + return pending.id; } function updatePendingUpload(id, updates = {}) { - const pending = pendingUploads.find((item) => item.id === id); - if (!pending) return; - Object.assign(pending, updates); - renderAssetList(); + const pending = pendingUploads.find((item) => item.id === id); + if (!pending) return; + Object.assign(pending, updates); + renderAssetList(); } function removePendingUpload(id) { - const index = pendingUploads.findIndex((item) => item.id === id); - if (index === -1) return; - pendingUploads.splice(index, 1); - renderAssetList(); + const index = pendingUploads.findIndex((item) => item.id === id); + if (index === -1) return; + pendingUploads.splice(index, 1); + renderAssetList(); } function resolvePendingUploadByName(name) { - if (!name) return; - const index = pendingUploads.findIndex((item) => item.name === name); - if (index === -1) return; - pendingUploads.splice(index, 1); - renderAssetList(); + if (!name) return; + const index = pendingUploads.findIndex((item) => item.name === name); + if (index === -1) return; + pendingUploads.splice(index, 1); + renderAssetList(); } function formatDurationLabel(durationMs) { - const totalSeconds = Math.max(0, Math.round(durationMs / 1000)); - const seconds = totalSeconds % 60; - const minutes = Math.floor(totalSeconds / 60) % 60; - const hours = Math.floor(totalSeconds / 3600); - const parts = []; - if (hours > 0) { - parts.push(`${hours}h`); - } - if (minutes > 0 || hours > 0) { - parts.push(`${minutes}m`); - } - if (seconds > 0 || parts.length === 0) { - parts.push(`${seconds}s`); - } - return parts.join(' '); + const totalSeconds = Math.max(0, Math.round(durationMs / 1000)); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + const parts = []; + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0 || hours > 0) { + parts.push(`${minutes}m`); + } + if (seconds > 0 || parts.length === 0) { + parts.push(`${seconds}s`); + } + return parts.join(" "); } function recordDuration(assetId, seconds) { - if (!Number.isFinite(seconds) || seconds <= 0) { - return; - } - const asset = assets.get(assetId); - if (!asset) { - return; - } - const nextMs = Math.round(seconds * 1000); - if (asset.durationMs === nextMs) { - return; - } - asset.durationMs = nextMs; - if (asset.id === selectedAssetId) { - updateSelectedAssetSummary(asset); - } - drawAndList(); + if (!Number.isFinite(seconds) || seconds <= 0) { + return; + } + const asset = assets.get(assetId); + if (!asset) { + return; + } + const nextMs = Math.round(seconds * 1000); + if (asset.durationMs === nextMs) { + return; + } + asset.durationMs = nextMs; + if (asset.id === selectedAssetId) { + updateSelectedAssetSummary(asset); + } + drawAndList(); } function hasDuration(asset) { - return asset && Number.isFinite(asset.durationMs) && asset.durationMs > 0 && (isAudioAsset(asset) || isVideoAsset(asset)); + return ( + asset && Number.isFinite(asset.durationMs) && asset.durationMs > 0 && (isAudioAsset(asset) || isVideoAsset(asset)) + ); } function getDurationBadge(asset) { - if (!hasDuration(asset)) { - return null; - } - return formatDurationLabel(asset.durationMs); + if (!hasDuration(asset)) { + return null; + } + return formatDurationLabel(asset.durationMs); } function setSpeedLabel(percent) { - if (!speedLabel) return; - speedLabel.textContent = `${Math.round(percent)}%`; + if (!speedLabel) return; + speedLabel.textContent = `${Math.round(percent)}%`; } function setAudioSpeedLabel(percentValue) { - if (!audioSpeedLabel) return; - const multiplier = Math.max(0, percentValue) / 100; - const formatted = multiplier >= 10 ? multiplier.toFixed(0) : multiplier.toFixed(2); - audioSpeedLabel.textContent = `${formatted}x`; + if (!audioSpeedLabel) return; + const multiplier = Math.max(0, percentValue) / 100; + const formatted = multiplier >= 10 ? multiplier.toFixed(0) : multiplier.toFixed(2); + audioSpeedLabel.textContent = `${formatted}x`; } function formatDelayLabel(ms) { - const numeric = Math.max(0, parseInt(ms, 10) || 0); - if (numeric >= 1000) { - const seconds = numeric / 1000; - const decimals = Number.isInteger(seconds) ? 0 : 1; - return `${seconds.toFixed(decimals)}s`; - } - return `${numeric}ms`; + const numeric = Math.max(0, parseInt(ms, 10) || 0); + if (numeric >= 1000) { + const seconds = numeric / 1000; + const decimals = Number.isInteger(seconds) ? 0 : 1; + return `${seconds.toFixed(decimals)}s`; + } + return `${numeric}ms`; } function setAudioDelayLabel(value) { - if (!audioDelayLabel) return; - audioDelayLabel.textContent = formatDelayLabel(value); + if (!audioDelayLabel) return; + audioDelayLabel.textContent = formatDelayLabel(value); } function setAudioPitchLabel(percentValue) { - if (!audioPitchLabel) return; - const numeric = Math.round(Math.max(0, percentValue)); - audioPitchLabel.textContent = `${numeric}%`; + if (!audioPitchLabel) return; + const numeric = Math.round(Math.max(0, percentValue)); + audioPitchLabel.textContent = `${numeric}%`; } function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); + return Math.min(max, Math.max(min, value)); } function sliderToVolume(sliderValue) { - const normalized = clamp(sliderValue, 0, VOLUME_SLIDER_MAX) / VOLUME_SLIDER_MAX; - const curved = normalized + VOLUME_CURVE_STRENGTH * normalized * (1 - normalized) * (1 - 2 * normalized); - return clamp(curved * SETTINGS.maxAssetVolumeFraction, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); + const normalized = clamp(sliderValue, 0, VOLUME_SLIDER_MAX) / VOLUME_SLIDER_MAX; + const curved = normalized + VOLUME_CURVE_STRENGTH * normalized * (1 - normalized) * (1 - 2 * normalized); + return clamp( + curved * SETTINGS.maxAssetVolumeFraction, + SETTINGS.minAssetVolumeFraction, + SETTINGS.maxAssetVolumeFraction, + ); } function volumeToSlider(volumeValue) { - const target = clamp(volumeValue ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction) / SETTINGS.maxAssetVolumeFraction; - let low = 0; - let high = VOLUME_SLIDER_MAX; - for (let i = 0; i < 24; i += 1) { - const mid = (low + high) / 2; - const midNormalized = sliderToVolume(mid) / SETTINGS.maxAssetVolumeFraction; - if (midNormalized < target) { - low = mid; - } else { - high = mid; - } + const target = + clamp(volumeValue ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction) / + SETTINGS.maxAssetVolumeFraction; + let low = 0; + let high = VOLUME_SLIDER_MAX; + for (let i = 0; i < 24; i += 1) { + const mid = (low + high) / 2; + const midNormalized = sliderToVolume(mid) / SETTINGS.maxAssetVolumeFraction; + if (midNormalized < target) { + low = mid; + } else { + high = mid; } - return Math.round(high); + } + return Math.round(high); } function setVolumeLabel(sliderValue) { - if (!volumeLabel) return; - const volumePercent = Math.round(sliderToVolume(sliderValue) * 100); - volumeLabel.textContent = `${volumePercent}%`; + if (!volumeLabel) return; + const volumePercent = Math.round(sliderToVolume(sliderValue) * 100); + volumeLabel.textContent = `${volumePercent}%`; } function queueAudioForUnlock(controller) { - if (!controller) return; - pendingAudioUnlock.add(controller); + if (!controller) return; + pendingAudioUnlock.add(controller); } function safePlay(controller) { - if (!controller?.element) return; - const playPromise = controller.element.play(); - if (playPromise?.catch) { - playPromise.catch(() => queueAudioForUnlock(controller)); - } + if (!controller?.element) return; + const playPromise = controller.element.play(); + if (playPromise?.catch) { + playPromise.catch(() => queueAudioForUnlock(controller)); + } } -if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width')); -if (widthInput) widthInput.addEventListener('change', () => commitSizeChange()); -if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height')); -if (heightInput) heightInput.addEventListener('change', () => commitSizeChange()); -if (speedInput) speedInput.addEventListener('input', updatePlaybackFromInputs); -if (volumeInput) volumeInput.addEventListener('input', updateVolumeFromInput); -if (audioLoopInput) audioLoopInput.addEventListener('change', updateAudioSettingsFromInputs); -if (audioDelayInput) audioDelayInput.addEventListener('input', () => { +if (widthInput) widthInput.addEventListener("input", () => handleSizeInputChange("width")); +if (widthInput) widthInput.addEventListener("change", () => commitSizeChange()); +if (heightInput) heightInput.addEventListener("input", () => handleSizeInputChange("height")); +if (heightInput) heightInput.addEventListener("change", () => commitSizeChange()); +if (speedInput) speedInput.addEventListener("input", updatePlaybackFromInputs); +if (volumeInput) volumeInput.addEventListener("input", updateVolumeFromInput); +if (audioLoopInput) audioLoopInput.addEventListener("change", updateAudioSettingsFromInputs); +if (audioDelayInput) + audioDelayInput.addEventListener("input", () => { setAudioDelayLabel(audioDelayInput.value); updateAudioSettingsFromInputs(); -}); -if (audioSpeedInput) audioSpeedInput.addEventListener('input', () => { + }); +if (audioSpeedInput) + audioSpeedInput.addEventListener("input", () => { setAudioSpeedLabel(audioSpeedInput.value); updateAudioSettingsFromInputs(); -}); -if (audioPitchInput) audioPitchInput.addEventListener('input', () => { + }); +if (audioPitchInput) + audioPitchInput.addEventListener("input", () => { setAudioPitchLabel(audioPitchInput.value); updateAudioSettingsFromInputs(); -}); + }); if (selectedDeleteBtn) { - selectedDeleteBtn.addEventListener('click', () => { - const asset = getSelectedAsset(); - if (!asset) return; - deleteAsset(asset); - }); + selectedDeleteBtn.addEventListener("click", () => { + const asset = getSelectedAsset(); + if (!asset) return; + deleteAsset(asset); + }); } -window.addEventListener('keydown', (event) => { - if (isFormInputElement(event.target)) { - return; - } +window.addEventListener("keydown", (event) => { + if (isFormInputElement(event.target)) { + return; + } - const asset = getSelectedAsset(); + const asset = getSelectedAsset(); - if ((event.key === 'Delete' || event.key === 'Backspace') && asset) { - event.preventDefault(); - deleteAsset(asset); - return; - } + if ((event.key === "Delete" || event.key === "Backspace") && asset) { + event.preventDefault(); + deleteAsset(asset); + return; + } - if (!asset || isAudioAsset(asset)) { - return; - } + if (!asset || isAudioAsset(asset)) { + return; + } - const step = event.shiftKey ? KEYBOARD_NUDGE_FAST_STEP : KEYBOARD_NUDGE_STEP; - let moved = false; + const step = event.shiftKey ? KEYBOARD_NUDGE_FAST_STEP : KEYBOARD_NUDGE_STEP; + let moved = false; - switch (event.key) { - case 'ArrowUp': - asset.y -= step; - moved = true; - break; - case 'ArrowDown': - asset.y += step; - moved = true; - break; - case 'ArrowLeft': - asset.x -= step; - moved = true; - break; - case 'ArrowRight': - asset.x += step; - moved = true; - break; - default: - break; - } + switch (event.key) { + case "ArrowUp": + asset.y -= step; + moved = true; + break; + case "ArrowDown": + asset.y += step; + moved = true; + break; + case "ArrowLeft": + asset.x -= step; + moved = true; + break; + case "ArrowRight": + asset.x += step; + moved = true; + break; + default: + break; + } - if (moved) { - event.preventDefault(); - updateRenderState(asset); - schedulePersistTransform(asset); - drawAndList(); - } + if (moved) { + event.preventDefault(); + updateRenderState(asset); + schedulePersistTransform(asset); + drawAndList(); + } }); function connect() { - const socket = new SockJS('/ws'); - stompClient = Stomp.over(socket); - stompClient.connect({}, () => { - stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => { - const body = JSON.parse(payload.body); - handleEvent(body); - }); - fetchAssets(); - }, (error) => { - console.warn('WebSocket connection issue', error); - setTimeout(() => showToast('Live updates connection interrupted. Retrying may be necessary.', 'warning'), 1000); - }); + const socket = new SockJS("/ws"); + stompClient = Stomp.over(socket); + stompClient.connect( + {}, + () => { + stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => { + const body = JSON.parse(payload.body); + handleEvent(body); + }); + fetchAssets(); + }, + (error) => { + console.warn("WebSocket connection issue", error); + setTimeout(() => showToast("Live updates connection interrupted. Retrying may be necessary.", "warning"), 1000); + }, + ); } function fetchAssets() { - fetch(`/api/channels/${broadcaster}/assets`) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to load assets'); - } - return r.json(); - }) - .then(renderAssets) - .catch(() => showToast('Unable to load assets. Please refresh.', 'error')); + fetch(`/api/channels/${broadcaster}/assets`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load assets"); + } + return r.json(); + }) + .then(renderAssets) + .catch(() => showToast("Unable to load assets. Please refresh.", "error")); } function fetchCanvasSettings() { - return fetch(`/api/channels/${broadcaster}/canvas`) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to load canvas'); - } - return r.json(); - }) - .then((settings) => { - canvasSettings = settings; - resizeCanvas(); - }) - .catch(() => { - resizeCanvas(); - showToast('Using default canvas size. Unable to load saved settings.', 'warning'); - }); + return fetch(`/api/channels/${broadcaster}/canvas`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load canvas"); + } + return r.json(); + }) + .then((settings) => { + canvasSettings = settings; + resizeCanvas(); + }) + .catch(() => { + resizeCanvas(); + showToast("Using default canvas size. Unable to load saved settings.", "warning"); + }); } function resizeCanvas() { - if (!overlay) { - return; - } - const bounds = overlay.getBoundingClientRect(); - const scale = Math.min(bounds.width / canvasSettings.width, bounds.height / canvasSettings.height); - const displayWidth = canvasSettings.width * scale; - const displayHeight = canvasSettings.height * scale; - canvas.width = canvasSettings.width; - canvas.height = canvasSettings.height; - canvas.style.width = `${displayWidth}px`; - canvas.style.height = `${displayHeight}px`; - canvas.style.left = `${(bounds.width - displayWidth) / 2}px`; - canvas.style.top = `${(bounds.height - displayHeight) / 2}px`; - if (canvasResolutionLabel) { - canvasResolutionLabel.textContent = `${canvasSettings.width} x ${canvasSettings.height}`; - } - if (canvasScaleLabel) { - canvasScaleLabel.textContent = `${Math.round(scale * 100)}%`; - } - requestDraw(); + if (!overlay) { + return; + } + const bounds = overlay.getBoundingClientRect(); + const scale = Math.min(bounds.width / canvasSettings.width, bounds.height / canvasSettings.height); + const displayWidth = canvasSettings.width * scale; + const displayHeight = canvasSettings.height * scale; + canvas.width = canvasSettings.width; + canvas.height = canvasSettings.height; + canvas.style.width = `${displayWidth}px`; + canvas.style.height = `${displayHeight}px`; + canvas.style.left = `${(bounds.width - displayWidth) / 2}px`; + canvas.style.top = `${(bounds.height - displayHeight) / 2}px`; + if (canvasResolutionLabel) { + canvasResolutionLabel.textContent = `${canvasSettings.width} x ${canvasSettings.height}`; + } + if (canvasScaleLabel) { + canvasScaleLabel.textContent = `${Math.round(scale * 100)}%`; + } + requestDraw(); } function renderAssets(list) { - layerOrder = []; - list.forEach((item) => storeAsset(item, { placement: 'append' })); - drawAndList(); + layerOrder = []; + list.forEach((item) => storeAsset(item, { placement: "append" })); + drawAndList(); } function storeAsset(asset, options = {}) { - if (!asset) return; - const placement = options.placement || 'keep'; - const existing = assets.get(asset.id); - const merged = existing ? { ...existing, ...asset } : { ...asset }; - const mediaChanged = existing && existing.url !== merged.url; - const previewChanged = existing && existing.previewUrl !== merged.previewUrl; - if (mediaChanged || previewChanged) { - clearMedia(asset.id); - } - const parsedCreatedAt = merged.createdAt ? new Date(merged.createdAt).getTime() : NaN; - const hasCreatedAtMs = typeof merged.createdAtMs === 'number' && Number.isFinite(merged.createdAtMs); - if (!hasCreatedAtMs) { - merged.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now(); - } - assets.set(asset.id, merged); - ensureLayerPosition(asset.id, existing ? 'keep' : placement); - if (!renderStates.has(asset.id)) { - renderStates.set(asset.id, { ...merged }); - } - resolvePendingUploadByName(asset.name); + if (!asset) return; + const placement = options.placement || "keep"; + const existing = assets.get(asset.id); + const merged = existing ? { ...existing, ...asset } : { ...asset }; + const mediaChanged = existing && existing.url !== merged.url; + const previewChanged = existing && existing.previewUrl !== merged.previewUrl; + if (mediaChanged || previewChanged) { + clearMedia(asset.id); + } + const parsedCreatedAt = merged.createdAt ? new Date(merged.createdAt).getTime() : NaN; + const hasCreatedAtMs = typeof merged.createdAtMs === "number" && Number.isFinite(merged.createdAtMs); + if (!hasCreatedAtMs) { + merged.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now(); + } + assets.set(asset.id, merged); + ensureLayerPosition(asset.id, existing ? "keep" : placement); + if (!renderStates.has(asset.id)) { + renderStates.set(asset.id, { ...merged }); + } + resolvePendingUploadByName(asset.name); } function updateRenderState(asset) { - if (!asset) return; - const state = renderStates.get(asset.id) || {}; - state.x = asset.x; - state.y = asset.y; - state.width = asset.width; - state.height = asset.height; - state.rotation = asset.rotation; - renderStates.set(asset.id, state); + if (!asset) return; + const state = renderStates.get(asset.id) || {}; + state.x = asset.x; + state.y = asset.y; + state.width = asset.width; + state.height = asset.height; + state.rotation = asset.rotation; + renderStates.set(asset.id, state); } function handleEvent(event) { - const assetId = event.assetId || event?.patch?.id || event?.payload?.id; - if (event.type === 'DELETED') { - assets.delete(assetId); - layerOrder = layerOrder.filter((id) => id !== assetId); - clearMedia(assetId); - renderStates.delete(assetId); - loopPlaybackState.delete(assetId); - cancelPendingTransform(assetId); - if (selectedAssetId === assetId) { - selectedAssetId = null; - } - } else if (event.patch) { - applyPatch(assetId, event.patch); - } else if (event.payload) { - storeAsset(event.payload); - if (!event.payload.hidden && !isVideoAsset(event.payload)) { - ensureMedia(event.payload); - if (isAudioAsset(event.payload) && !loopPlaybackState.has(event.payload.id)) { - loopPlaybackState.set(event.payload.id, true); - } - } else { - clearMedia(event.payload.id); - loopPlaybackState.delete(event.payload.id); - } + const assetId = event.assetId || event?.patch?.id || event?.payload?.id; + if (event.type === "DELETED") { + assets.delete(assetId); + layerOrder = layerOrder.filter((id) => id !== assetId); + clearMedia(assetId); + renderStates.delete(assetId); + loopPlaybackState.delete(assetId); + cancelPendingTransform(assetId); + if (selectedAssetId === assetId) { + selectedAssetId = null; } - drawAndList(); + } else if (event.patch) { + applyPatch(assetId, event.patch); + } else if (event.payload) { + storeAsset(event.payload); + if (!event.payload.hidden && !isVideoAsset(event.payload)) { + ensureMedia(event.payload); + if (isAudioAsset(event.payload) && !loopPlaybackState.has(event.payload.id)) { + loopPlaybackState.set(event.payload.id, true); + } + } else { + clearMedia(event.payload.id); + loopPlaybackState.delete(event.payload.id); + } + } + drawAndList(); } function applyPatch(assetId, patch) { - if (!assetId || !patch) { - return; - } - const existing = assets.get(assetId); - if (!existing) { - return; - } - const merged = { ...existing, ...patch }; - const isAudio = isAudioAsset(merged); - if (patch.hidden) { - clearMedia(assetId); - loopPlaybackState.delete(assetId); - } - const targetLayer = Number.isFinite(patch.layer) - ? patch.layer - : (Number.isFinite(patch.zIndex) ? patch.zIndex : null); - if (!isAudio && Number.isFinite(targetLayer)) { - const currentOrder = getLayerOrder().filter((id) => id !== assetId); - const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); - currentOrder.splice(insertIndex, 0, assetId); - layerOrder = currentOrder; - } - storeAsset(merged); - if (!isAudio) { - updateRenderState(merged); - } + if (!assetId || !patch) { + return; + } + const existing = assets.get(assetId); + if (!existing) { + return; + } + const merged = { ...existing, ...patch }; + const isAudio = isAudioAsset(merged); + if (patch.hidden) { + clearMedia(assetId); + loopPlaybackState.delete(assetId); + } + const targetLayer = Number.isFinite(patch.layer) ? patch.layer : Number.isFinite(patch.zIndex) ? patch.zIndex : null; + if (!isAudio && Number.isFinite(targetLayer)) { + const currentOrder = getLayerOrder().filter((id) => id !== assetId); + const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); + currentOrder.splice(insertIndex, 0, assetId); + layerOrder = currentOrder; + } + storeAsset(merged); + if (!isAudio) { + updateRenderState(merged); + } } function drawAndList() { - requestDraw(); - renderAssetList(); + requestDraw(); + renderAssetList(); } function requestDraw() { - if (drawPending) { - return; - } - drawPending = true; - requestAnimationFrame(() => { - drawPending = false; - draw(); - }); + if (drawPending) { + return; + } + drawPending = true; + requestAnimationFrame(() => { + drawPending = false; + draw(); + }); } function draw() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - getRenderOrder().forEach((asset) => drawAsset(asset)); + ctx.clearRect(0, 0, canvas.width, canvas.height); + getRenderOrder().forEach((asset) => drawAsset(asset)); } function drawAsset(asset) { - const renderState = smoothState(asset); - const halfWidth = renderState.width / 2; - const halfHeight = renderState.height / 2; - ctx.save(); - ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); - ctx.rotate(renderState.rotation * Math.PI / 180); + const renderState = smoothState(asset); + const halfWidth = renderState.width / 2; + const halfHeight = renderState.height / 2; + ctx.save(); + ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); + ctx.rotate((renderState.rotation * Math.PI) / 180); - if (isAudioAsset(asset)) { - autoStartAudio(asset); - ctx.restore(); - return; - } - - let drawSource = null; - let ready = false; - let showPlayOverlay = false; - if (isVideoAsset(asset) || isGifAsset(asset)) { - drawSource = ensureCanvasPreview(asset); - ready = isDrawable(drawSource); - showPlayOverlay = true; - } else { - const media = ensureMedia(asset); - drawSource = media?.isAnimated ? media.bitmap : media; - ready = isDrawable(media); - } - if (ready && drawSource) { - ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; - ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); - } else { - ctx.globalAlpha = asset.hidden ? 0.2 : 0.4; - ctx.fillStyle = 'rgba(124, 58, 237, 0.35)'; - ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height); - } - - if (asset.hidden) { - ctx.fillStyle = 'rgba(15, 23, 42, 0.35)'; - ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height); - } - - ctx.globalAlpha = 1; - ctx.strokeStyle = asset.id === selectedAssetId ? 'rgba(124, 58, 237, 0.9)' : 'rgba(255, 255, 255, 0.4)'; - ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1; - ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []); - ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height); - if (showPlayOverlay) { - drawPlayOverlay(renderState); - } - if (asset.id === selectedAssetId) { - drawSelectionOverlay(renderState); - } + if (isAudioAsset(asset)) { + autoStartAudio(asset); ctx.restore(); + return; + } + + let drawSource = null; + let ready = false; + let showPlayOverlay = false; + if (isVideoAsset(asset) || isGifAsset(asset)) { + drawSource = ensureCanvasPreview(asset); + ready = isDrawable(drawSource); + showPlayOverlay = true; + } else { + const media = ensureMedia(asset); + drawSource = media?.isAnimated ? media.bitmap : media; + ready = isDrawable(media); + } + if (ready && drawSource) { + ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; + ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); + } else { + ctx.globalAlpha = asset.hidden ? 0.2 : 0.4; + ctx.fillStyle = "rgba(124, 58, 237, 0.35)"; + ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height); + } + + if (asset.hidden) { + ctx.fillStyle = "rgba(15, 23, 42, 0.35)"; + ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height); + } + + ctx.globalAlpha = 1; + ctx.strokeStyle = asset.id === selectedAssetId ? "rgba(124, 58, 237, 0.9)" : "rgba(255, 255, 255, 0.4)"; + ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1; + ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []); + ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height); + if (showPlayOverlay) { + drawPlayOverlay(renderState); + } + if (asset.id === selectedAssetId) { + drawSelectionOverlay(renderState); + } + ctx.restore(); } function smoothState(asset) { - const previous = renderStates.get(asset.id) || { ...asset }; - const factor = interactionState && interactionState.assetId === asset.id ? 0.45 : 0.18; - previous.x = lerp(previous.x, asset.x, factor); - previous.y = lerp(previous.y, asset.y, factor); - previous.width = lerp(previous.width, asset.width, factor); - previous.height = lerp(previous.height, asset.height, factor); - previous.rotation = smoothAngle(previous.rotation, asset.rotation, factor); - renderStates.set(asset.id, previous); - return previous; + const previous = renderStates.get(asset.id) || { ...asset }; + const factor = interactionState && interactionState.assetId === asset.id ? 0.45 : 0.18; + previous.x = lerp(previous.x, asset.x, factor); + previous.y = lerp(previous.y, asset.y, factor); + previous.width = lerp(previous.width, asset.width, factor); + previous.height = lerp(previous.height, asset.height, factor); + previous.rotation = smoothAngle(previous.rotation, asset.rotation, factor); + renderStates.set(asset.id, previous); + return previous; } function smoothAngle(current, target, factor) { - let delta = ((target - current + 180) % 360) - 180; - return current + delta * factor; + let delta = ((target - current + 180) % 360) - 180; + return current + delta * factor; } function lerp(a, b, t) { - return a + (b - a) * t; + return a + (b - a) * t; } function drawPlayOverlay(asset) { - const size = Math.max(24, Math.min(asset.width, asset.height) * 0.2); - ctx.save(); - ctx.fillStyle = 'rgba(15, 23, 42, 0.35)'; - ctx.beginPath(); - ctx.arc(0, 0, size * 0.75, 0, Math.PI * 2); - ctx.fill(); + const size = Math.max(24, Math.min(asset.width, asset.height) * 0.2); + ctx.save(); + ctx.fillStyle = "rgba(15, 23, 42, 0.35)"; + ctx.beginPath(); + ctx.arc(0, 0, size * 0.75, 0, Math.PI * 2); + ctx.fill(); - ctx.fillStyle = '#ffffff'; - ctx.beginPath(); - ctx.moveTo(-size * 0.3, -size * 0.45); - ctx.lineTo(size * 0.55, 0); - ctx.lineTo(-size * 0.3, size * 0.45); - ctx.closePath(); - ctx.fill(); - ctx.restore(); + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.moveTo(-size * 0.3, -size * 0.45); + ctx.lineTo(size * 0.55, 0); + ctx.lineTo(-size * 0.3, size * 0.45); + ctx.closePath(); + ctx.fill(); + ctx.restore(); } function drawSelectionOverlay(asset) { - const halfWidth = asset.width / 2; - const halfHeight = asset.height / 2; - ctx.save(); - ctx.setLineDash([6, 4]); - ctx.strokeStyle = 'rgba(124, 58, 237, 0.9)'; - ctx.lineWidth = 1.5; - ctx.strokeRect(-halfWidth, -halfHeight, asset.width, asset.height); + const halfWidth = asset.width / 2; + const halfHeight = asset.height / 2; + ctx.save(); + ctx.setLineDash([6, 4]); + ctx.strokeStyle = "rgba(124, 58, 237, 0.9)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(-halfWidth, -halfHeight, asset.width, asset.height); - const handles = getHandlePositions(asset); - handles.forEach((handle) => { - drawHandle(handle.x - halfWidth, handle.y - halfHeight, false); - }); + const handles = getHandlePositions(asset); + handles.forEach((handle) => { + drawHandle(handle.x - halfWidth, handle.y - halfHeight, false); + }); - drawHandle(0, -halfHeight - ROTATE_HANDLE_OFFSET, true); - ctx.restore(); + drawHandle(0, -halfHeight - ROTATE_HANDLE_OFFSET, true); + ctx.restore(); } function drawHandle(x, y, isRotation) { - ctx.save(); - ctx.setLineDash([]); - ctx.fillStyle = isRotation ? 'rgba(96, 165, 250, 0.9)' : 'rgba(124, 58, 237, 0.9)'; - ctx.strokeStyle = '#0f172a'; - ctx.lineWidth = 1; - if (isRotation) { - ctx.beginPath(); - ctx.arc(x, y, HANDLE_SIZE * 0.65, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - } else { - ctx.fillRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); - ctx.strokeRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); - } - ctx.restore(); + ctx.save(); + ctx.setLineDash([]); + ctx.fillStyle = isRotation ? "rgba(96, 165, 250, 0.9)" : "rgba(124, 58, 237, 0.9)"; + ctx.strokeStyle = "#0f172a"; + ctx.lineWidth = 1; + if (isRotation) { + ctx.beginPath(); + ctx.arc(x, y, HANDLE_SIZE * 0.65, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } else { + ctx.fillRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); + ctx.strokeRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); + } + ctx.restore(); } function getHandlePositions(asset) { - return [ - { x: 0, y: 0, type: 'nw' }, - { x: asset.width / 2, y: 0, type: 'n' }, - { x: asset.width, y: 0, type: 'ne' }, - { x: asset.width, y: asset.height / 2, type: 'e' }, - { x: asset.width, y: asset.height, type: 'se' }, - { x: asset.width / 2, y: asset.height, type: 's' }, - { x: 0, y: asset.height, type: 'sw' }, - { x: 0, y: asset.height / 2, type: 'w' } - ]; + return [ + { x: 0, y: 0, type: "nw" }, + { x: asset.width / 2, y: 0, type: "n" }, + { x: asset.width, y: 0, type: "ne" }, + { x: asset.width, y: asset.height / 2, type: "e" }, + { x: asset.width, y: asset.height, type: "se" }, + { x: asset.width / 2, y: asset.height, type: "s" }, + { x: 0, y: asset.height, type: "sw" }, + { x: 0, y: asset.height / 2, type: "w" }, + ]; } function rotatePoint(x, y, degrees) { - const radians = degrees * Math.PI / 180; - const cos = Math.cos(radians); - const sin = Math.sin(radians); - return { - x: x * cos - y * sin, - y: x * sin + y * cos - }; + const radians = (degrees * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + return { + x: x * cos - y * sin, + y: x * sin + y * cos, + }; } function pointerToLocal(asset, point) { - const centerX = asset.x + asset.width / 2; - const centerY = asset.y + asset.height / 2; - const dx = point.x - centerX; - const dy = point.y - centerY; - const rotated = rotatePoint(dx, dy, -asset.rotation); - return { - x: rotated.x + asset.width / 2, - y: rotated.y + asset.height / 2 - }; + const centerX = asset.x + asset.width / 2; + const centerY = asset.y + asset.height / 2; + const dx = point.x - centerX; + const dy = point.y - centerY; + const rotated = rotatePoint(dx, dy, -asset.rotation); + return { + x: rotated.x + asset.width / 2, + y: rotated.y + asset.height / 2, + }; } function angleFromCenter(asset, point) { - const centerX = asset.x + asset.width / 2; - const centerY = asset.y + asset.height / 2; - return Math.atan2(point.y - centerY, point.x - centerX) * 180 / Math.PI; + const centerX = asset.x + asset.width / 2; + const centerY = asset.y + asset.height / 2; + return (Math.atan2(point.y - centerY, point.x - centerX) * 180) / Math.PI; } function hitHandle(asset, point) { - const local = pointerToLocal(asset, point); - const tolerance = HANDLE_SIZE * 1.2; - const rotationDistance = Math.hypot(local.x - asset.width / 2, local.y + ROTATE_HANDLE_OFFSET); - if (Math.abs(local.y + ROTATE_HANDLE_OFFSET) <= tolerance && rotationDistance <= tolerance * 1.5) { - return 'rotate'; + const local = pointerToLocal(asset, point); + const tolerance = HANDLE_SIZE * 1.2; + const rotationDistance = Math.hypot(local.x - asset.width / 2, local.y + ROTATE_HANDLE_OFFSET); + if (Math.abs(local.y + ROTATE_HANDLE_OFFSET) <= tolerance && rotationDistance <= tolerance * 1.5) { + return "rotate"; + } + for (const handle of getHandlePositions(asset)) { + if (Math.abs(local.x - handle.x) <= tolerance && Math.abs(local.y - handle.y) <= tolerance) { + return handle.type; } - for (const handle of getHandlePositions(asset)) { - if (Math.abs(local.x - handle.x) <= tolerance && Math.abs(local.y - handle.y) <= tolerance) { - return handle.type; - } - } - return null; + } + return null; } function cursorForHandle(handle) { - switch (handle) { - case 'nw': - case 'se': - return 'nwse-resize'; - case 'ne': - case 'sw': - return 'nesw-resize'; - case 'n': - case 's': - return 'ns-resize'; - case 'e': - case 'w': - return 'ew-resize'; - case 'rotate': - return 'grab'; - default: - return 'default'; - } + switch (handle) { + case "nw": + case "se": + return "nwse-resize"; + case "ne": + case "sw": + return "nesw-resize"; + case "n": + case "s": + return "ns-resize"; + case "e": + case "w": + return "ew-resize"; + case "rotate": + return "grab"; + default: + return "default"; + } } function resizeFromHandle(state, point) { - const asset = assets.get(state.assetId); - if (!asset) return; - const basis = state.original; - const local = pointerToLocal(basis, point); - const handle = state.handle; - const minSize = 10; + const asset = assets.get(state.assetId); + if (!asset) return; + const basis = state.original; + const local = pointerToLocal(basis, point); + const handle = state.handle; + const minSize = 10; - let nextWidth = basis.width; - let nextHeight = basis.height; - let offsetX = 0; - let offsetY = 0; + let nextWidth = basis.width; + let nextHeight = basis.height; + let offsetX = 0; + let offsetY = 0; - if (handle.includes('e')) { - nextWidth = basis.width + (local.x - state.startLocal.x); - } - if (handle.includes('s')) { - nextHeight = basis.height + (local.y - state.startLocal.y); - } - if (handle.includes('w')) { - nextWidth = basis.width - (local.x - state.startLocal.x); - } - if (handle.includes('n')) { - nextHeight = basis.height - (local.y - state.startLocal.y); - } + if (handle.includes("e")) { + nextWidth = basis.width + (local.x - state.startLocal.x); + } + if (handle.includes("s")) { + nextHeight = basis.height + (local.y - state.startLocal.y); + } + if (handle.includes("w")) { + nextWidth = basis.width - (local.x - state.startLocal.x); + } + if (handle.includes("n")) { + nextHeight = basis.height - (local.y - state.startLocal.y); + } - const ratio = isAspectLocked(asset.id) ? (getAssetAspectRatio(asset) || basis.width / Math.max(basis.height, 1)) : null; - if (ratio) { - const widthChanged = handle.includes('e') || handle.includes('w'); - const heightChanged = handle.includes('n') || handle.includes('s'); - if (widthChanged && !heightChanged) { - nextHeight = nextWidth / ratio; - } else if (!widthChanged && heightChanged) { - nextWidth = nextHeight * ratio; - } else { - if (Math.abs(nextWidth - basis.width) > Math.abs(nextHeight - basis.height)) { - nextHeight = nextWidth / ratio; - } else { - nextWidth = nextHeight * ratio; - } - } + const ratio = isAspectLocked(asset.id) ? getAssetAspectRatio(asset) || basis.width / Math.max(basis.height, 1) : null; + if (ratio) { + const widthChanged = handle.includes("e") || handle.includes("w"); + const heightChanged = handle.includes("n") || handle.includes("s"); + if (widthChanged && !heightChanged) { + nextHeight = nextWidth / ratio; + } else if (!widthChanged && heightChanged) { + nextWidth = nextHeight * ratio; + } else { + if (Math.abs(nextWidth - basis.width) > Math.abs(nextHeight - basis.height)) { + nextHeight = nextWidth / ratio; + } else { + nextWidth = nextHeight * ratio; + } } + } - nextWidth = Math.max(minSize, nextWidth); - nextHeight = Math.max(minSize, nextHeight); + nextWidth = Math.max(minSize, nextWidth); + nextHeight = Math.max(minSize, nextHeight); - if (handle.includes('w')) { - offsetX = basis.width - nextWidth; - } - if (handle.includes('n')) { - offsetY = basis.height - nextHeight; - } + if (handle.includes("w")) { + offsetX = basis.width - nextWidth; + } + if (handle.includes("n")) { + offsetY = basis.height - nextHeight; + } - const shift = rotatePoint(offsetX, offsetY, basis.rotation); - asset.x = basis.x + shift.x; - asset.y = basis.y + shift.y; - asset.width = nextWidth; - asset.height = nextHeight; - updateRenderState(asset); - requestDraw(); + const shift = rotatePoint(offsetX, offsetY, basis.rotation); + asset.x = basis.x + shift.x; + asset.y = basis.y + shift.y; + asset.width = nextWidth; + asset.height = nextHeight; + updateRenderState(asset); + requestDraw(); } function updateHoverCursor(point) { - const asset = getSelectedAsset(); - if (asset) { - const handle = hitHandle(asset, point); - if (handle) { - canvas.style.cursor = cursorForHandle(handle); - return; - } + const asset = getSelectedAsset(); + if (asset) { + const handle = hitHandle(asset, point); + if (handle) { + canvas.style.cursor = cursorForHandle(handle); + return; } - const hit = findAssetAtPoint(point.x, point.y); - canvas.style.cursor = hit ? 'move' : 'default'; + } + const hit = findAssetAtPoint(point.x, point.y); + canvas.style.cursor = hit ? "move" : "default"; } function isVideoAsset(asset) { - const type = asset?.mediaType || asset?.originalMediaType || ''; - return type.startsWith('video/'); + const type = asset?.mediaType || asset?.originalMediaType || ""; + return type.startsWith("video/"); } function isAudioAsset(asset) { - const type = asset?.mediaType || asset?.originalMediaType || ''; - return type.startsWith('audio/'); + const type = asset?.mediaType || asset?.originalMediaType || ""; + return type.startsWith("audio/"); } function isVideoElement(element) { - return element && element.tagName === 'VIDEO'; + return element && element.tagName === "VIDEO"; } function getDisplayMediaType(asset) { - const raw = asset.originalMediaType || asset.mediaType || ''; - if (!raw) { - return 'Unknown'; - } - const parts = raw.split('/'); - return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase(); + const raw = asset.originalMediaType || asset.mediaType || ""; + if (!raw) { + return "Unknown"; + } + const parts = raw.split("/"); + return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase(); } function isGifAsset(asset) { - return asset?.mediaType?.toLowerCase() === 'image/gif'; + return asset?.mediaType?.toLowerCase() === "image/gif"; } function isDrawable(element) { - if (!element) { - return false; - } - if (element.isAnimated) { - return !!element.bitmap; - } - if (isVideoElement(element)) { - return element.readyState >= 2; - } - if (typeof ImageBitmap !== 'undefined' && element instanceof ImageBitmap) { - return true; - } - return !!element.complete; + if (!element) { + return false; + } + if (element.isAnimated) { + return !!element.bitmap; + } + if (isVideoElement(element)) { + return element.readyState >= 2; + } + if (typeof ImageBitmap !== "undefined" && element instanceof ImageBitmap) { + return true; + } + return !!element.complete; } function clearMedia(assetId) { - mediaCache.delete(assetId); - const cachedPreview = previewCache.get(assetId); - if (cachedPreview && cachedPreview.startsWith('blob:')) { - URL.revokeObjectURL(cachedPreview); - } - previewCache.delete(assetId); - previewImageCache.delete(assetId); - const animated = animatedCache.get(assetId); - if (animated) { - animated.cancelled = true; - clearTimeout(animated.timeout); - animated.bitmap?.close?.(); - animated.decoder?.close?.(); - animatedCache.delete(assetId); - } - const audio = audioControllers.get(assetId); - if (audio) { - if (audio.delayTimeout) { - clearTimeout(audio.delayTimeout); - } - audio.element.pause(); - audio.element.currentTime = 0; - audioControllers.delete(assetId); + mediaCache.delete(assetId); + const cachedPreview = previewCache.get(assetId); + if (cachedPreview && cachedPreview.startsWith("blob:")) { + URL.revokeObjectURL(cachedPreview); + } + previewCache.delete(assetId); + previewImageCache.delete(assetId); + const animated = animatedCache.get(assetId); + if (animated) { + animated.cancelled = true; + clearTimeout(animated.timeout); + animated.bitmap?.close?.(); + animated.decoder?.close?.(); + animatedCache.delete(assetId); + } + const audio = audioControllers.get(assetId); + if (audio) { + if (audio.delayTimeout) { + clearTimeout(audio.delayTimeout); } + audio.element.pause(); + audio.element.currentTime = 0; + audioControllers.delete(assetId); + } } function ensureAudioController(asset) { - const cached = audioControllers.get(asset.id); - if (cached && cached.src === asset.url) { - applyAudioSettings(cached, asset); - return cached; - } + const cached = audioControllers.get(asset.id); + if (cached && cached.src === asset.url) { + applyAudioSettings(cached, asset); + return cached; + } - if (cached) { - clearMedia(asset.id); - } + if (cached) { + clearMedia(asset.id); + } - const element = new Audio(asset.url); - element.autoplay = true; - element.controls = true; - element.preload = 'auto'; - element.addEventListener('loadedmetadata', () => recordDuration(asset.id, element.duration)); - const controller = { - id: asset.id, - src: asset.url, - element, - delayTimeout: null, - loopEnabled: false, - delayMs: 0, - baseDelayMs: 0 - }; - element.onended = () => handleAudioEnded(asset.id); - audioControllers.set(asset.id, controller); - applyAudioSettings(controller, asset, true); - return controller; + const element = new Audio(asset.url); + element.autoplay = true; + element.controls = true; + element.preload = "auto"; + element.addEventListener("loadedmetadata", () => recordDuration(asset.id, element.duration)); + const controller = { + id: asset.id, + src: asset.url, + element, + delayTimeout: null, + loopEnabled: false, + delayMs: 0, + baseDelayMs: 0, + }; + element.onended = () => handleAudioEnded(asset.id); + audioControllers.set(asset.id, controller); + applyAudioSettings(controller, asset, true); + return controller; } function applyAudioSettings(controller, asset, resetPosition = false) { - controller.loopEnabled = !!asset.audioLoop; - controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0); - controller.delayMs = controller.baseDelayMs; - const speed = Math.max(0.25, asset.audioSpeed || 1); - const pitch = Math.max(0.5, asset.audioPitch || 1); - controller.element.playbackRate = speed * pitch; - const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); - controller.element.volume = volume; - if (resetPosition) { - controller.element.currentTime = 0; - controller.element.pause(); - } + controller.loopEnabled = !!asset.audioLoop; + controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0); + controller.delayMs = controller.baseDelayMs; + const speed = Math.max(0.25, asset.audioSpeed || 1); + const pitch = Math.max(0.5, asset.audioPitch || 1); + controller.element.playbackRate = speed * pitch; + const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); + controller.element.volume = volume; + if (resetPosition) { + controller.element.currentTime = 0; + controller.element.pause(); + } } function handleAudioEnded(assetId) { - const controller = audioControllers.get(assetId); - if (!controller) return; - controller.element.currentTime = 0; - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - } - if (controller.loopEnabled) { - controller.delayTimeout = setTimeout(() => { - safePlay(controller); - }, controller.delayMs); - } else { - controller.element.pause(); - } + const controller = audioControllers.get(assetId); + if (!controller) return; + controller.element.currentTime = 0; + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + } + if (controller.loopEnabled) { + controller.delayTimeout = setTimeout(() => { + safePlay(controller); + }, controller.delayMs); + } else { + controller.element.pause(); + } } function stopAudio(assetId) { - const controller = audioControllers.get(assetId); - if (!controller) return; - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - } - controller.element.pause(); - controller.element.currentTime = 0; - controller.delayTimeout = null; - controller.delayMs = controller.baseDelayMs; + const controller = audioControllers.get(assetId); + if (!controller) return; + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + } + controller.element.pause(); + controller.element.currentTime = 0; + controller.delayTimeout = null; + controller.delayMs = controller.baseDelayMs; } function autoStartAudio(asset) { - if (!isAudioAsset(asset) || asset.hidden) { - return; - } - ensureAudioController(asset); + if (!isAudioAsset(asset) || asset.hidden) { + return; + } + ensureAudioController(asset); } function ensureMedia(asset) { - const cached = mediaCache.get(asset.id); - if (cached && cached.src !== asset.url) { - clearMedia(asset.id); - } - if (cached && cached.src === asset.url) { - applyMediaSettings(cached, asset); - return cached; - } + const cached = mediaCache.get(asset.id); + if (cached && cached.src !== asset.url) { + clearMedia(asset.id); + } + if (cached && cached.src === asset.url) { + applyMediaSettings(cached, asset); + return cached; + } - if (isAudioAsset(asset)) { - ensureAudioController(asset); - mediaCache.delete(asset.id); - return null; - } + if (isAudioAsset(asset)) { + ensureAudioController(asset); + mediaCache.delete(asset.id); + return null; + } - if (isVideoAsset(asset)) { - return null; - } + if (isVideoAsset(asset)) { + return null; + } - if (isGifAsset(asset) && 'ImageDecoder' in window) { - const animated = ensureAnimatedImage(asset); - if (animated) { - mediaCache.set(asset.id, animated); - return animated; - } + if (isGifAsset(asset) && "ImageDecoder" in window) { + const animated = ensureAnimatedImage(asset); + if (animated) { + mediaCache.set(asset.id, animated); + return animated; } + } - const element = isVideoAsset(asset) ? document.createElement('video') : new Image(); - element.crossOrigin = 'anonymous'; - if (isVideoElement(element)) { - element.loop = true; - const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); - element.muted = volume === 0; - element.volume = Math.min(volume, 1); - element.playsInline = true; - element.autoplay = false; - element.preload = 'metadata'; - element.onloadeddata = requestDraw; - element.onloadedmetadata = () => recordDuration(asset.id, element.duration); - element.src = asset.url; - const playback = asset.speed ?? 1; - element.playbackRate = Math.max(playback, 0.01); - element.pause(); - } else { - element.onload = requestDraw; - element.src = asset.url; - } - mediaCache.set(asset.id, element); - return element; + const element = isVideoAsset(asset) ? document.createElement("video") : new Image(); + element.crossOrigin = "anonymous"; + if (isVideoElement(element)) { + element.loop = true; + const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); + element.muted = volume === 0; + element.volume = Math.min(volume, 1); + element.playsInline = true; + element.autoplay = false; + element.preload = "metadata"; + element.onloadeddata = requestDraw; + element.onloadedmetadata = () => recordDuration(asset.id, element.duration); + element.src = asset.url; + const playback = asset.speed ?? 1; + element.playbackRate = Math.max(playback, 0.01); + element.pause(); + } else { + element.onload = requestDraw; + element.src = asset.url; + } + mediaCache.set(asset.id, element); + return element; } function ensureAnimatedImage(asset) { - const cached = animatedCache.get(asset.id); - if (cached && cached.url === asset.url) { - return cached; - } + const cached = animatedCache.get(asset.id); + if (cached && cached.url === asset.url) { + return cached; + } - if (cached) { - clearMedia(asset.id); - } + if (cached) { + clearMedia(asset.id); + } - const controller = { - id: asset.id, - url: asset.url, - src: asset.url, - decoder: null, - bitmap: null, - timeout: null, - cancelled: false, - isAnimated: true - }; + const controller = { + id: asset.id, + url: asset.url, + src: asset.url, + decoder: null, + bitmap: null, + timeout: null, + cancelled: false, + isAnimated: true, + }; - fetch(asset.url) - .then((r) => r.blob()) - .then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' })) - .then((decoder) => { - if (controller.cancelled) { - decoder.close?.(); - return null; - } - controller.decoder = decoder; - scheduleNextFrame(controller); - return controller; - }) - .catch(() => { - animatedCache.delete(asset.id); - }); + fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" })) + .then((decoder) => { + if (controller.cancelled) { + decoder.close?.(); + return null; + } + controller.decoder = decoder; + scheduleNextFrame(controller); + return controller; + }) + .catch(() => { + animatedCache.delete(asset.id); + }); - animatedCache.set(asset.id, controller); - return controller; + animatedCache.set(asset.id, controller); + return controller; } function scheduleNextFrame(controller) { - if (controller.cancelled || !controller.decoder) { + if (controller.cancelled || !controller.decoder) { + return; + } + controller.decoder + .decode() + .then(({ image, complete }) => { + if (controller.cancelled) { + image.close?.(); return; - } - controller.decoder.decode().then(({ image, complete }) => { - if (controller.cancelled) { - image.close?.(); - return; - } - controller.bitmap?.close?.(); - createImageBitmap(image) - .then((bitmap) => { - controller.bitmap = bitmap; - requestDraw(); - }) - .finally(() => image.close?.()); + } + controller.bitmap?.close?.(); + createImageBitmap(image) + .then((bitmap) => { + controller.bitmap = bitmap; + requestDraw(); + }) + .finally(() => image.close?.()); - const durationMicros = image.duration || 0; - const delay = durationMicros > 0 ? durationMicros / 1000 : 100; - const hasMore = !complete; - controller.timeout = setTimeout(() => { - if (controller.cancelled) { - return; - } - if (hasMore) { - scheduleNextFrame(controller); - } else { - controller.decoder.reset(); - scheduleNextFrame(controller); - } - }, delay); - }).catch(() => { - animatedCache.delete(controller.id); + const durationMicros = image.duration || 0; + const delay = durationMicros > 0 ? durationMicros / 1000 : 100; + const hasMore = !complete; + controller.timeout = setTimeout(() => { + if (controller.cancelled) { + return; + } + if (hasMore) { + scheduleNextFrame(controller); + } else { + controller.decoder.reset(); + scheduleNextFrame(controller); + } + }, delay); + }) + .catch(() => { + animatedCache.delete(controller.id); }); } function applyMediaSettings(element, asset) { - if (!isVideoElement(element)) { - return; - } - const nextSpeed = asset.speed ?? 1; - const effectiveSpeed = Math.max(nextSpeed, 0.01); - if (element.playbackRate !== effectiveSpeed) { - element.playbackRate = effectiveSpeed; - } - const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); - element.muted = volume === 0; - element.volume = Math.min(volume, 1); - if (nextSpeed === 0) { - element.pause(); - return; - } - const playPromise = element.play(); - if (playPromise?.catch) { - playPromise.catch(() => { }); - } + if (!isVideoElement(element)) { + return; + } + const nextSpeed = asset.speed ?? 1; + const effectiveSpeed = Math.max(nextSpeed, 0.01); + if (element.playbackRate !== effectiveSpeed) { + element.playbackRate = effectiveSpeed; + } + const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); + element.muted = volume === 0; + element.volume = Math.min(volume, 1); + if (nextSpeed === 0) { + element.pause(); + return; + } + const playPromise = element.play(); + if (playPromise?.catch) { + playPromise.catch(() => {}); + } } function renderAssetList() { - const list = document.getElementById('asset-list'); - if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { - controlsPlaceholder.appendChild(controlsPanel); - } - if (controlsPanel) { - controlsPanel.classList.add('hidden'); - } - list.innerHTML = ''; + const list = document.getElementById("asset-list"); + if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { + controlsPlaceholder.appendChild(controlsPanel); + } + if (controlsPanel) { + controlsPanel.classList.add("hidden"); + } + list.innerHTML = ""; - const hasAssets = assets.size > 0; - const hasPending = pendingUploads.length > 0; - - if (!hasAssets && !hasPending) { - selectedAssetId = null; - if (assetInspector) { - assetInspector.classList.add('hidden'); - } - const empty = document.createElement('li'); - empty.textContent = ''; - list.appendChild(empty); - updateSelectedAssetControls(); - return; - } + const hasAssets = assets.size > 0; + const hasPending = pendingUploads.length > 0; + if (!hasAssets && !hasPending) { + selectedAssetId = null; if (assetInspector) { - assetInspector.classList.toggle('hidden', !hasAssets); + assetInspector.classList.add("hidden"); } - - const pendingItems = [...pendingUploads].sort((a, b) => (a.createdAtMs || 0) - (b.createdAtMs || 0)); - pendingItems.forEach((pending) => { - list.appendChild(createPendingListItem(pending)); - }); - - const audioAssets = getAudioAssets(); - const sortedAssets = [...audioAssets, ...getAssetsByLayer()]; - sortedAssets.forEach((asset) => { - const li = document.createElement('li'); - li.className = 'asset-item'; - if (asset.id === selectedAssetId) { - li.classList.add('selected'); - } - li.classList.toggle('is-hidden', !!asset.hidden); - - const row = document.createElement('div'); - row.className = 'asset-row'; - - const preview = createPreviewElement(asset); - - const meta = document.createElement('div'); - meta.className = 'meta'; - const name = document.createElement('strong'); - name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; - const details = document.createElement('small'); - details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)}`; - meta.appendChild(name); - meta.appendChild(details); - - const actions = document.createElement('div'); - actions.className = 'actions'; - - if (isAudioAsset(asset)) { - const playBtn = document.createElement('button'); - playBtn.type = 'button'; - playBtn.className = 'ghost icon-button'; - const isLooping = !!asset.audioLoop; - const isPlayingLoop = getLoopPlaybackState(asset); - updatePlayButtonIcon(playBtn, isLooping, isPlayingLoop); - playBtn.title = isLooping - ? (isPlayingLoop ? 'Pause looping audio' : 'Play looping audio') - : 'Play audio'; - playBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const nextPlay = isLooping - ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) - : true; - if (isLooping) { - loopPlaybackState.set(asset.id, nextPlay); - updatePlayButtonIcon(playBtn, true, nextPlay); - playBtn.title = nextPlay ? 'Pause looping audio' : 'Play looping audio'; - } - triggerAudioPlayback(asset, nextPlay); - }); - actions.appendChild(playBtn); - } - - if (!isAudioAsset(asset)) { - const toggleBtn = document.createElement('button'); - toggleBtn.type = 'button'; - toggleBtn.className = 'ghost icon-button'; - toggleBtn.innerHTML = ``; - toggleBtn.title = asset.hidden ? 'Show asset' : 'Hide asset'; - toggleBtn.addEventListener('click', (e) => { - e.stopPropagation(); - selectedAssetId = asset.id; - updateVisibility(asset, !asset.hidden); - }); - actions.appendChild(toggleBtn); - } - - row.appendChild(preview); - row.appendChild(meta); - row.appendChild(actions); - - li.addEventListener('click', () => { - selectedAssetId = asset.id; - updateRenderState(asset); - drawAndList(); - }); - - li.appendChild(row); - list.appendChild(li); - }); - + const empty = document.createElement("li"); + empty.textContent = ""; + list.appendChild(empty); updateSelectedAssetControls(); -} + return; + } -function createPendingListItem(pending) { - const li = document.createElement('li'); - li.className = 'asset-item pending'; + if (assetInspector) { + assetInspector.classList.toggle("hidden", !hasAssets); + } - const row = document.createElement('div'); - row.className = 'asset-row'; + const pendingItems = [...pendingUploads].sort((a, b) => (a.createdAtMs || 0) - (b.createdAtMs || 0)); + pendingItems.forEach((pending) => { + list.appendChild(createPendingListItem(pending)); + }); - const preview = document.createElement('div'); - preview.className = 'asset-preview pending-preview'; - preview.innerHTML = ''; + const audioAssets = getAudioAssets(); + const sortedAssets = [...audioAssets, ...getAssetsByLayer()]; + sortedAssets.forEach((asset) => { + const li = document.createElement("li"); + li.className = "asset-item"; + if (asset.id === selectedAssetId) { + li.classList.add("selected"); + } + li.classList.toggle("is-hidden", !!asset.hidden); - const meta = document.createElement('div'); - meta.className = 'meta'; - const name = document.createElement('strong'); - name.textContent = pending?.name || 'Uploading asset'; - const details = document.createElement('small'); - details.textContent = pending.status === 'processing' ? 'Processing upload…' : 'Uploading…'; + const row = document.createElement("div"); + row.className = "asset-row"; + + const preview = createPreviewElement(asset); + + const meta = document.createElement("div"); + meta.className = "meta"; + const name = document.createElement("strong"); + name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; + const details = document.createElement("small"); + details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)}`; meta.appendChild(name); meta.appendChild(details); - const progress = document.createElement('div'); - progress.className = 'upload-progress'; - const bar = document.createElement('div'); - bar.className = 'upload-progress-bar'; - if (pending.status === 'processing') { - bar.classList.add('is-processing'); + const actions = document.createElement("div"); + actions.className = "actions"; + + if (isAudioAsset(asset)) { + const playBtn = document.createElement("button"); + playBtn.type = "button"; + playBtn.className = "ghost icon-button"; + const isLooping = !!asset.audioLoop; + const isPlayingLoop = getLoopPlaybackState(asset); + updatePlayButtonIcon(playBtn, isLooping, isPlayingLoop); + playBtn.title = isLooping ? (isPlayingLoop ? "Pause looping audio" : "Play looping audio") : "Play audio"; + playBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const nextPlay = isLooping ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) : true; + if (isLooping) { + loopPlaybackState.set(asset.id, nextPlay); + updatePlayButtonIcon(playBtn, true, nextPlay); + playBtn.title = nextPlay ? "Pause looping audio" : "Play looping audio"; + } + triggerAudioPlayback(asset, nextPlay); + }); + actions.appendChild(playBtn); + } + + if (!isAudioAsset(asset)) { + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = "ghost icon-button"; + toggleBtn.innerHTML = ``; + toggleBtn.title = asset.hidden ? "Show asset" : "Hide asset"; + toggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + selectedAssetId = asset.id; + updateVisibility(asset, !asset.hidden); + }); + actions.appendChild(toggleBtn); } - progress.appendChild(bar); - meta.appendChild(progress); row.appendChild(preview); row.appendChild(meta); - li.appendChild(row); + row.appendChild(actions); - return li; + li.addEventListener("click", () => { + selectedAssetId = asset.id; + updateRenderState(asset); + drawAndList(); + }); + + li.appendChild(row); + list.appendChild(li); + }); + + updateSelectedAssetControls(); } -function createBadge(label, extraClass = '') { - const badge = document.createElement('span'); - badge.className = `badge ${extraClass}`.trim(); - badge.textContent = label; - return badge; +function createPendingListItem(pending) { + const li = document.createElement("li"); + li.className = "asset-item pending"; + + const row = document.createElement("div"); + row.className = "asset-row"; + + const preview = document.createElement("div"); + preview.className = "asset-preview pending-preview"; + preview.innerHTML = ''; + + const meta = document.createElement("div"); + meta.className = "meta"; + const name = document.createElement("strong"); + name.textContent = pending?.name || "Uploading asset"; + const details = document.createElement("small"); + details.textContent = pending.status === "processing" ? "Processing upload…" : "Uploading…"; + meta.appendChild(name); + meta.appendChild(details); + + const progress = document.createElement("div"); + progress.className = "upload-progress"; + const bar = document.createElement("div"); + bar.className = "upload-progress-bar"; + if (pending.status === "processing") { + bar.classList.add("is-processing"); + } + progress.appendChild(bar); + meta.appendChild(progress); + + row.appendChild(preview); + row.appendChild(meta); + li.appendChild(row); + + return li; +} + +function createBadge(label, extraClass = "") { + const badge = document.createElement("span"); + badge.className = `badge ${extraClass}`.trim(); + badge.textContent = label; + return badge; } function getLoopPlaybackState(asset) { - if (!isAudioAsset(asset) || !asset.audioLoop) { - return false; - } - if (loopPlaybackState.has(asset.id)) { - return loopPlaybackState.get(asset.id); - } - const isVisible = asset.hidden === false || asset.hidden === undefined; - loopPlaybackState.set(asset.id, isVisible); - return isVisible; + if (!isAudioAsset(asset) || !asset.audioLoop) { + return false; + } + if (loopPlaybackState.has(asset.id)) { + return loopPlaybackState.get(asset.id); + } + const isVisible = asset.hidden === false || asset.hidden === undefined; + loopPlaybackState.set(asset.id, isVisible); + return isVisible; } function updatePlayButtonIcon(button, isLooping, isPlayingLoop) { - const icon = isLooping ? (isPlayingLoop ? 'fa-pause' : 'fa-play') : 'fa-play'; - button.innerHTML = ``; + const icon = isLooping ? (isPlayingLoop ? "fa-pause" : "fa-play") : "fa-play"; + button.innerHTML = ``; } function createPreviewElement(asset) { - if (isAudioAsset(asset)) { - const icon = document.createElement('div'); - icon.className = 'asset-preview audio-icon'; - icon.innerHTML = ''; - return icon; - } - if (isVideoAsset(asset) || isGifAsset(asset)) { - const still = document.createElement('div'); - still.className = 'asset-preview still'; - still.setAttribute('aria-label', asset.name || 'Asset preview'); + if (isAudioAsset(asset)) { + const icon = document.createElement("div"); + icon.className = "asset-preview audio-icon"; + icon.innerHTML = ''; + return icon; + } + if (isVideoAsset(asset) || isGifAsset(asset)) { + const still = document.createElement("div"); + still.className = "asset-preview still"; + still.setAttribute("aria-label", asset.name || "Asset preview"); - const overlay = document.createElement('div'); - overlay.className = 'preview-overlay'; - overlay.innerHTML = ''; - still.appendChild(overlay); + const overlay = document.createElement("div"); + overlay.className = "preview-overlay"; + overlay.innerHTML = ''; + still.appendChild(overlay); - loadPreviewFrame(asset, still); - return still; - } + loadPreviewFrame(asset, still); + return still; + } - const img = document.createElement('img'); - img.className = 'asset-preview'; - img.src = asset.url; - img.alt = asset.name || 'Asset preview'; - img.loading = 'lazy'; - return img; + const img = document.createElement("img"); + img.className = "asset-preview"; + img.src = asset.url; + img.alt = asset.name || "Asset preview"; + img.loading = "lazy"; + return img; } function fetchPreviewData(asset) { - if (!asset) return Promise.resolve(null); - const cached = previewCache.get(asset.id); - if (cached) { - return Promise.resolve(cached); - } + if (!asset) return Promise.resolve(null); + const cached = previewCache.get(asset.id); + if (cached) { + return Promise.resolve(cached); + } - const fallback = () => { - const fallbackPromise = isVideoAsset(asset) - ? captureVideoFrame(asset) - : isGifAsset(asset) - ? captureGifFrame(asset) - : Promise.resolve(null); - return fallbackPromise.then((result) => { - if (!result) { - return null; - } - previewCache.set(asset.id, result); - return result; - }); + const fallback = () => { + const fallbackPromise = isVideoAsset(asset) + ? captureVideoFrame(asset) + : isGifAsset(asset) + ? captureGifFrame(asset) + : Promise.resolve(null); + return fallbackPromise.then((result) => { + if (!result) { + return null; + } + previewCache.set(asset.id, result); + return result; + }); + }; + + if (!asset.previewUrl) { + return fallback(); + } + + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + previewCache.set(asset.id, asset.previewUrl); + resolve(asset.previewUrl); }; - - if (!asset.previewUrl) { - return fallback(); - } - - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - previewCache.set(asset.id, asset.previewUrl); - resolve(asset.previewUrl); - }; - img.onerror = () => fallback().then(resolve); - img.src = asset.previewUrl; - }).catch(() => null); + img.onerror = () => fallback().then(resolve); + img.src = asset.previewUrl; + }).catch(() => null); } function loadPreviewFrame(asset, element) { - if (!asset || !element) return; - fetchPreviewData(asset) - .then((dataUrl) => { - if (!dataUrl) return; - applyPreviewFrame(element, dataUrl); - }) - .catch(() => { }); + if (!asset || !element) return; + fetchPreviewData(asset) + .then((dataUrl) => { + if (!dataUrl) return; + applyPreviewFrame(element, dataUrl); + }) + .catch(() => {}); } function applyPreviewFrame(element, dataUrl) { - if (!element || !dataUrl) return; - element.style.backgroundImage = `url(${dataUrl})`; - element.classList.add('has-image'); + if (!element || !dataUrl) return; + element.style.backgroundImage = `url(${dataUrl})`; + element.classList.add("has-image"); } function ensureCanvasPreview(asset) { - const cachedData = previewCache.get(asset.id); - const cachedImage = previewImageCache.get(asset.id); - if (cachedData && cachedImage?.src === cachedData) { - return cachedImage.image; - } + const cachedData = previewCache.get(asset.id); + const cachedImage = previewImageCache.get(asset.id); + if (cachedData && cachedImage?.src === cachedData) { + return cachedImage.image; + } - if (cachedData) { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = requestDraw; - img.src = cachedData; - previewImageCache.set(asset.id, { src: cachedData, image: img }); - return img; - } + if (cachedData) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = requestDraw; + img.src = cachedData; + previewImageCache.set(asset.id, { src: cachedData, image: img }); + return img; + } - fetchPreviewData(asset) - .then((dataUrl) => { - if (!dataUrl) return; - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = requestDraw; - img.src = dataUrl; - previewImageCache.set(asset.id, { src: dataUrl, image: img }); - }) - .catch(() => { }); + fetchPreviewData(asset) + .then((dataUrl) => { + if (!dataUrl) return; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = requestDraw; + img.src = dataUrl; + previewImageCache.set(asset.id, { src: dataUrl, image: img }); + }) + .catch(() => {}); - return null; + return null; } function captureVideoFrame(asset) { - return new Promise((resolve) => { - const video = document.createElement('video'); - video.crossOrigin = 'anonymous'; - video.preload = 'auto'; - video.muted = true; - video.playsInline = true; - video.src = asset.url; + return new Promise((resolve) => { + const video = document.createElement("video"); + video.crossOrigin = "anonymous"; + video.preload = "auto"; + video.muted = true; + video.playsInline = true; + video.src = asset.url; - video.addEventListener('loadedmetadata', () => recordDuration(asset.id, video.duration), { once: true }); + video.addEventListener("loadedmetadata", () => recordDuration(asset.id, video.duration), { once: true }); - const cleanup = () => { - video.pause(); - video.removeAttribute('src'); - video.load(); - }; + const cleanup = () => { + video.pause(); + video.removeAttribute("src"); + video.load(); + }; - video.addEventListener('loadeddata', () => { - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth || asset.width || 0; - canvas.height = video.videoHeight || asset.height || 0; - if (!canvas.width || !canvas.height) { - cleanup(); - resolve(null); - return; - } - const context = canvas.getContext('2d'); - context.drawImage(video, 0, 0, canvas.width, canvas.height); - try { - const dataUrl = canvas.toDataURL('image/png'); - resolve(dataUrl); - } catch (err) { - resolve(null); - } - cleanup(); - }, { once: true }); + video.addEventListener( + "loadeddata", + () => { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth || asset.width || 0; + canvas.height = video.videoHeight || asset.height || 0; + if (!canvas.width || !canvas.height) { + cleanup(); + resolve(null); + return; + } + const context = canvas.getContext("2d"); + context.drawImage(video, 0, 0, canvas.width, canvas.height); + try { + const dataUrl = canvas.toDataURL("image/png"); + resolve(dataUrl); + } catch (err) { + resolve(null); + } + cleanup(); + }, + { once: true }, + ); - video.addEventListener('error', () => { - cleanup(); - resolve(null); - }, { once: true }); - }); + video.addEventListener( + "error", + () => { + cleanup(); + resolve(null); + }, + { once: true }, + ); + }); } function captureGifFrame(asset) { - if (!('ImageDecoder' in window)) { - return Promise.resolve(null); - } - return fetch(asset.url) - .then((r) => r.blob()) - .then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' })) - .then((decoder) => decoder.decode({ frameIndex: 0 })) - .then(({ image }) => { - const canvas = document.createElement('canvas'); - canvas.width = image.displayWidth || asset.width || 0; - canvas.height = image.displayHeight || asset.height || 0; - const ctx2d = canvas.getContext('2d'); - ctx2d.drawImage(image, 0, 0, canvas.width, canvas.height); - image.close?.(); - try { - return canvas.toDataURL('image/png'); - } catch (err) { - return null; - } - }) - .catch(() => null); + if (!("ImageDecoder" in window)) { + return Promise.resolve(null); + } + return fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" })) + .then((decoder) => decoder.decode({ frameIndex: 0 })) + .then(({ image }) => { + const canvas = document.createElement("canvas"); + canvas.width = image.displayWidth || asset.width || 0; + canvas.height = image.displayHeight || asset.height || 0; + const ctx2d = canvas.getContext("2d"); + ctx2d.drawImage(image, 0, 0, canvas.width, canvas.height); + image.close?.(); + try { + return canvas.toDataURL("image/png"); + } catch (err) { + return null; + } + }) + .catch(() => null); } function getSelectedAsset() { - return selectedAssetId ? assets.get(selectedAssetId) : null; + return selectedAssetId ? assets.get(selectedAssetId) : null; } function updateSelectedAssetControls(asset = getSelectedAsset()) { - if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { - controlsPlaceholder.appendChild(controlsPanel); - } + if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { + controlsPlaceholder.appendChild(controlsPanel); + } - updateSelectedAssetSummary(asset); + updateSelectedAssetSummary(asset); - if (!controlsPanel || !asset) { - if (controlsPanel) controlsPanel.classList.add('hidden'); - return; - } + if (!controlsPanel || !asset) { + if (controlsPanel) controlsPanel.classList.add("hidden"); + return; + } - controlsPanel.classList.remove('hidden'); - lastSizeInputChanged = null; - if (selectedZLabel) { - selectedZLabel.textContent = getLayerValue(asset.id); - } + controlsPanel.classList.remove("hidden"); + lastSizeInputChanged = null; + if (selectedZLabel) { + selectedZLabel.textContent = getLayerValue(asset.id); + } - if (widthInput) widthInput.value = Math.round(asset.width); - if (heightInput) heightInput.value = Math.round(asset.height); - if (aspectLockInput) { - aspectLockInput.checked = isAspectLocked(asset.id); - aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); + if (widthInput) widthInput.value = Math.round(asset.width); + if (heightInput) heightInput.value = Math.round(asset.height); + if (aspectLockInput) { + aspectLockInput.checked = isAspectLocked(asset.id); + aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); + } + const hideLayout = isAudioAsset(asset); + if (layoutSection) { + layoutSection.classList.toggle("hidden", hideLayout); + const layoutControls = layoutSection.querySelectorAll("input, button"); + layoutControls.forEach((control) => { + control.disabled = hideLayout; + control.classList.toggle("disabled", hideLayout); + }); + } + if (assetActionButtons.length) { + assetActionButtons.forEach((button) => { + const allowForAudio = button.dataset.audioEnabled === "true"; + const disableButton = hideLayout && !allowForAudio; + button.disabled = disableButton; + button.classList.toggle("disabled", disableButton); + }); + } + if (speedInput) { + const percent = Math.round((asset.speed ?? 1) * 100); + speedInput.value = Math.min(1000, Math.max(0, percent)); + setSpeedLabel(speedInput.value); + } + if (playbackSection) { + const shouldShowPlayback = isVideoAsset(asset); + playbackSection.classList.toggle("hidden", !shouldShowPlayback); + speedInput?.classList?.toggle("disabled", !shouldShowPlayback); + } + if (volumeSection) { + const showVolume = isAudioAsset(asset) || isVideoAsset(asset); + volumeSection.classList.toggle("hidden", !showVolume); + const volumeControls = volumeSection.querySelectorAll("input"); + volumeControls.forEach((control) => { + control.disabled = !showVolume; + control.classList.toggle("disabled", !showVolume); + }); + if (showVolume && volumeInput) { + const sliderValue = volumeToSlider(asset.audioVolume ?? 1); + volumeInput.value = sliderValue; + setVolumeLabel(sliderValue); } - const hideLayout = isAudioAsset(asset); - if (layoutSection) { - layoutSection.classList.toggle('hidden', hideLayout); - const layoutControls = layoutSection.querySelectorAll('input, button'); - layoutControls.forEach((control) => { - control.disabled = hideLayout; - control.classList.toggle('disabled', hideLayout); - }); - } - if (assetActionButtons.length) { - assetActionButtons.forEach((button) => { - const allowForAudio = button.dataset.audioEnabled === 'true'; - const disableButton = hideLayout && !allowForAudio; - button.disabled = disableButton; - button.classList.toggle('disabled', disableButton); - }); - } - if (speedInput) { - const percent = Math.round((asset.speed ?? 1) * 100); - speedInput.value = Math.min(1000, Math.max(0, percent)); - setSpeedLabel(speedInput.value); - } - if (playbackSection) { - const shouldShowPlayback = isVideoAsset(asset); - playbackSection.classList.toggle('hidden', !shouldShowPlayback); - speedInput?.classList?.toggle('disabled', !shouldShowPlayback); - } - if (volumeSection) { - const showVolume = isAudioAsset(asset) || isVideoAsset(asset); - volumeSection.classList.toggle('hidden', !showVolume); - const volumeControls = volumeSection.querySelectorAll('input'); - volumeControls.forEach((control) => { - control.disabled = !showVolume; - control.classList.toggle('disabled', !showVolume); - }); - if (showVolume && volumeInput) { - const sliderValue = volumeToSlider(asset.audioVolume ?? 1); - volumeInput.value = sliderValue; - setVolumeLabel(sliderValue); - } - } - if (audioSection) { - const showAudio = isAudioAsset(asset); - audioSection.classList.toggle('hidden', !showAudio); - const audioInputs = [audioLoopInput, audioDelayInput, audioSpeedInput, audioPitchInput]; - audioInputs.forEach((input) => { - if (!input) return; - input.disabled = !showAudio; - input.parentElement?.classList?.toggle('disabled', !showAudio); - }); - if (showAudio) { - audioLoopInput.checked = !!asset.audioLoop; - const delayMs = clamp(Math.max(0, asset.audioDelayMillis ?? 0), 0, 30000); - audioDelayInput.value = delayMs; - setAudioDelayLabel(delayMs); - const audioSpeedPercent = clamp(Math.round(Math.max(0.25, asset.audioSpeed ?? 1) * 100), 25, 400); - audioSpeedInput.value = audioSpeedPercent; - setAudioSpeedLabel(audioSpeedPercent); - const pitchPercent = clamp(Math.round(Math.max(0.5, asset.audioPitch ?? 1) * 100), 50, 200); - audioPitchInput.value = pitchPercent; - setAudioPitchLabel(pitchPercent); - } + } + if (audioSection) { + const showAudio = isAudioAsset(asset); + audioSection.classList.toggle("hidden", !showAudio); + const audioInputs = [audioLoopInput, audioDelayInput, audioSpeedInput, audioPitchInput]; + audioInputs.forEach((input) => { + if (!input) return; + input.disabled = !showAudio; + input.parentElement?.classList?.toggle("disabled", !showAudio); + }); + if (showAudio) { + audioLoopInput.checked = !!asset.audioLoop; + const delayMs = clamp(Math.max(0, asset.audioDelayMillis ?? 0), 0, 30000); + audioDelayInput.value = delayMs; + setAudioDelayLabel(delayMs); + const audioSpeedPercent = clamp(Math.round(Math.max(0.25, asset.audioSpeed ?? 1) * 100), 25, 400); + audioSpeedInput.value = audioSpeedPercent; + setAudioSpeedLabel(audioSpeedPercent); + const pitchPercent = clamp(Math.round(Math.max(0.5, asset.audioPitch ?? 1) * 100), 50, 200); + audioPitchInput.value = pitchPercent; + setAudioPitchLabel(pitchPercent); } + } } function updateSelectedAssetSummary(asset) { - if (assetInspector) { - assetInspector.classList.toggle('hidden', !asset && !assets.size); - } + if (assetInspector) { + assetInspector.classList.toggle("hidden", !asset && !assets.size); + } - ensureDurationMetadata(asset); + ensureDurationMetadata(asset); - if (selectedAssetName) { - selectedAssetName.textContent = asset ? (asset.name || `Asset ${asset.id.slice(0, 6)}`) : 'Choose an asset'; + if (selectedAssetName) { + selectedAssetName.textContent = asset ? asset.name || `Asset ${asset.id.slice(0, 6)}` : "Choose an asset"; + } + if (selectedAssetMeta) { + selectedAssetMeta.textContent = asset + ? getDisplayMediaType(asset) + : "Pick an asset in the list to adjust its placement and playback."; + } + if (selectedAssetResolution) { + if (asset) { + selectedAssetResolution.textContent = `${Math.round(asset.width)}×${Math.round(asset.height)}`; + selectedAssetResolution.classList.remove("hidden"); + } else { + selectedAssetResolution.textContent = ""; + selectedAssetResolution.classList.add("hidden"); } - if (selectedAssetMeta) { - selectedAssetMeta.textContent = asset - ? getDisplayMediaType(asset) - : 'Pick an asset in the list to adjust its placement and playback.'; + } + if (selectedAssetIdLabel) { + if (asset) { + selectedAssetIdLabel.textContent = `ID: ${asset.id}`; + selectedAssetIdLabel.classList.remove("hidden"); + } else { + selectedAssetIdLabel.classList.add("hidden"); + selectedAssetIdLabel.textContent = ""; } - if (selectedAssetResolution) { - if (asset) { - selectedAssetResolution.textContent = `${Math.round(asset.width)}×${Math.round(asset.height)}`; - selectedAssetResolution.classList.remove('hidden'); - } else { - selectedAssetResolution.textContent = ''; - selectedAssetResolution.classList.add('hidden'); + } + if (selectedAssetBadges) { + selectedAssetBadges.innerHTML = ""; + if (asset) { + selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset))); + const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : ""; + if (aspectLabel) { + selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle")); + } + const durationLabel = getDurationBadge(asset); + if (durationLabel) { + selectedAssetBadges.appendChild(createBadge(durationLabel, "subtle")); + } + } + } + if (selectedVisibilityBtn) { + selectedVisibilityBtn.disabled = !asset; + selectedVisibilityBtn.onclick = null; + if (asset && isAudioAsset(asset)) { + const isLooping = !!asset.audioLoop; + const isPlayingLoop = getLoopPlaybackState(asset); + updatePlayButtonIcon(selectedVisibilityBtn, isLooping, isPlayingLoop); + selectedVisibilityBtn.title = isLooping + ? isPlayingLoop + ? "Pause looping audio" + : "Play looping audio" + : "Play audio"; + selectedVisibilityBtn.onclick = () => { + const nextPlay = isLooping ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) : true; + if (isLooping) { + loopPlaybackState.set(asset.id, nextPlay); + updatePlayButtonIcon(selectedVisibilityBtn, true, nextPlay); + selectedVisibilityBtn.title = nextPlay ? "Pause looping audio" : "Play looping audio"; } + triggerAudioPlayback(asset, nextPlay); + }; + } else if (asset) { + selectedVisibilityBtn.title = asset.hidden ? "Show asset" : "Hide asset"; + selectedVisibilityBtn.innerHTML = ``; + selectedVisibilityBtn.onclick = () => updateVisibility(asset, !asset.hidden); + } else { + selectedVisibilityBtn.title = "Toggle visibility"; + selectedVisibilityBtn.innerHTML = ''; } - if (selectedAssetIdLabel) { - if (asset) { - selectedAssetIdLabel.textContent = `ID: ${asset.id}`; - selectedAssetIdLabel.classList.remove('hidden'); - } else { - selectedAssetIdLabel.classList.add('hidden'); - selectedAssetIdLabel.textContent = ''; - } - } - if (selectedAssetBadges) { - selectedAssetBadges.innerHTML = ''; - if (asset) { - selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset))); - const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : ''; - if (aspectLabel) { - selectedAssetBadges.appendChild(createBadge(aspectLabel, 'subtle')); - } - const durationLabel = getDurationBadge(asset); - if (durationLabel) { - selectedAssetBadges.appendChild(createBadge(durationLabel, 'subtle')); - } - } - } - if (selectedVisibilityBtn) { - selectedVisibilityBtn.disabled = !asset; - selectedVisibilityBtn.onclick = null; - if (asset && isAudioAsset(asset)) { - const isLooping = !!asset.audioLoop; - const isPlayingLoop = getLoopPlaybackState(asset); - updatePlayButtonIcon(selectedVisibilityBtn, isLooping, isPlayingLoop); - selectedVisibilityBtn.title = isLooping - ? (isPlayingLoop ? 'Pause looping audio' : 'Play looping audio') - : 'Play audio'; - selectedVisibilityBtn.onclick = () => { - const nextPlay = isLooping - ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) - : true; - if (isLooping) { - loopPlaybackState.set(asset.id, nextPlay); - updatePlayButtonIcon(selectedVisibilityBtn, true, nextPlay); - selectedVisibilityBtn.title = nextPlay ? 'Pause looping audio' : 'Play looping audio'; - } - triggerAudioPlayback(asset, nextPlay); - }; - } else if (asset) { - selectedVisibilityBtn.title = asset.hidden ? 'Show asset' : 'Hide asset'; - selectedVisibilityBtn.innerHTML = ``; - selectedVisibilityBtn.onclick = () => updateVisibility(asset, !asset.hidden); - } else { - selectedVisibilityBtn.title = 'Toggle visibility'; - selectedVisibilityBtn.innerHTML = ''; - } - } - if (selectedDeleteBtn) { - selectedDeleteBtn.disabled = !asset; - selectedDeleteBtn.title = asset ? 'Delete asset' : 'Delete asset'; - } + } + if (selectedDeleteBtn) { + selectedDeleteBtn.disabled = !asset; + selectedDeleteBtn.title = asset ? "Delete asset" : "Delete asset"; + } } function ensureDurationMetadata(asset) { - if (!asset || hasDuration(asset) || (!isVideoAsset(asset) && !isAudioAsset(asset))) { - return; - } + if (!asset || hasDuration(asset) || (!isVideoAsset(asset) && !isAudioAsset(asset))) { + return; + } - const element = document.createElement(isVideoAsset(asset) ? 'video' : 'audio'); - element.preload = 'metadata'; - element.muted = true; - element.playsInline = true; - element.src = asset.url; + const element = document.createElement(isVideoAsset(asset) ? "video" : "audio"); + element.preload = "metadata"; + element.muted = true; + element.playsInline = true; + element.src = asset.url; - const cleanup = () => { - element.removeAttribute('src'); - element.load(); - }; + const cleanup = () => { + element.removeAttribute("src"); + element.load(); + }; - element.addEventListener('loadedmetadata', () => { - recordDuration(asset.id, element.duration); - cleanup(); - }, { once: true }); + element.addEventListener( + "loadedmetadata", + () => { + recordDuration(asset.id, element.duration); + cleanup(); + }, + { once: true }, + ); - element.addEventListener('error', cleanup, { once: true }); + element.addEventListener("error", cleanup, { once: true }); } function applyTransformFromInputs() { - const asset = getSelectedAsset(); - if (!asset) return; - const locked = isAspectLocked(asset.id); - const ratio = getAssetAspectRatio(asset); - let nextWidth = parseFloat(widthInput?.value) || asset.width; - let nextHeight = parseFloat(heightInput?.value) || asset.height; + const asset = getSelectedAsset(); + if (!asset) return; + const locked = isAspectLocked(asset.id); + const ratio = getAssetAspectRatio(asset); + let nextWidth = parseFloat(widthInput?.value) || asset.width; + let nextHeight = parseFloat(heightInput?.value) || asset.height; - if (locked && ratio) { - if (lastSizeInputChanged === 'height') { - nextWidth = nextHeight * ratio; - if (widthInput) widthInput.value = Math.round(nextWidth); - } else { - nextHeight = nextWidth / ratio; - if (heightInput) heightInput.value = Math.round(nextHeight); - } + if (locked && ratio) { + if (lastSizeInputChanged === "height") { + nextWidth = nextHeight * ratio; + if (widthInput) widthInput.value = Math.round(nextWidth); + } else { + nextHeight = nextWidth / ratio; + if (heightInput) heightInput.value = Math.round(nextHeight); } + } - asset.width = Math.max(10, nextWidth); - asset.height = Math.max(10, nextHeight); - updateRenderState(asset); - persistTransform(asset); - drawAndList(); + asset.width = Math.max(10, nextWidth); + asset.height = Math.max(10, nextHeight); + updateRenderState(asset); + persistTransform(asset); + drawAndList(); } function updatePlaybackFromInputs() { - const asset = getSelectedAsset(); - if (!asset || !isVideoAsset(asset)) return; - const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100)); - setSpeedLabel(percent); - asset.speed = percent / 100; - updateRenderState(asset); - schedulePersistTransform(asset); - const media = mediaCache.get(asset.id); - if (media) { - applyMediaSettings(media, asset); - } - drawAndList(); + const asset = getSelectedAsset(); + if (!asset || !isVideoAsset(asset)) return; + const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100)); + setSpeedLabel(percent); + asset.speed = percent / 100; + updateRenderState(asset); + schedulePersistTransform(asset); + const media = mediaCache.get(asset.id); + if (media) { + applyMediaSettings(media, asset); + } + drawAndList(); } function updateVolumeFromInput() { - const asset = getSelectedAsset(); - if (!asset || !(isVideoAsset(asset) || isAudioAsset(asset))) return; - const sliderValue = Math.max(0, Math.min(VOLUME_SLIDER_MAX, parseFloat(volumeInput?.value) || 100)); - const volumeValue = sliderToVolume(sliderValue); - setVolumeLabel(sliderValue); - asset.audioVolume = volumeValue; - const media = mediaCache.get(asset.id); - if (media) { - applyMediaSettings(media, asset); - } - if (isAudioAsset(asset)) { - const controller = ensureAudioController(asset); - applyAudioSettings(controller, asset); - } - schedulePersistTransform(asset); - drawAndList(); + const asset = getSelectedAsset(); + if (!asset || !(isVideoAsset(asset) || isAudioAsset(asset))) return; + const sliderValue = Math.max(0, Math.min(VOLUME_SLIDER_MAX, parseFloat(volumeInput?.value) || 100)); + const volumeValue = sliderToVolume(sliderValue); + setVolumeLabel(sliderValue); + asset.audioVolume = volumeValue; + const media = mediaCache.get(asset.id); + if (media) { + applyMediaSettings(media, asset); + } + if (isAudioAsset(asset)) { + const controller = ensureAudioController(asset); + applyAudioSettings(controller, asset); + } + schedulePersistTransform(asset); + drawAndList(); } function updateAudioSettingsFromInputs() { - const asset = getSelectedAsset(); - if (!asset || !isAudioAsset(asset)) return; - asset.audioLoop = !!audioLoopInput?.checked; - const delayMs = clamp(Math.max(0, parseInt(audioDelayInput?.value || '0', 10)), 0, 30000); - asset.audioDelayMillis = delayMs; - setAudioDelayLabel(delayMs); - if (audioDelayInput) audioDelayInput.value = delayMs; - const nextAudioSpeedPercent = clamp(Math.max(25, parseInt(audioSpeedInput?.value || '100', 10)), 25, 400); - setAudioSpeedLabel(nextAudioSpeedPercent); - if (audioSpeedInput) audioSpeedInput.value = nextAudioSpeedPercent; - asset.audioSpeed = Math.max(0.25, (nextAudioSpeedPercent / 100)); - const nextAudioPitchPercent = clamp(Math.max(50, parseInt(audioPitchInput?.value || '100', 10)), 50, 200); - setAudioPitchLabel(nextAudioPitchPercent); - if (audioPitchInput) audioPitchInput.value = nextAudioPitchPercent; - asset.audioPitch = Math.max(0.5, (nextAudioPitchPercent / 100)); - const controller = ensureAudioController(asset); - applyAudioSettings(controller, asset); - schedulePersistTransform(asset); - drawAndList(); + const asset = getSelectedAsset(); + if (!asset || !isAudioAsset(asset)) return; + asset.audioLoop = !!audioLoopInput?.checked; + const delayMs = clamp(Math.max(0, parseInt(audioDelayInput?.value || "0", 10)), 0, 30000); + asset.audioDelayMillis = delayMs; + setAudioDelayLabel(delayMs); + if (audioDelayInput) audioDelayInput.value = delayMs; + const nextAudioSpeedPercent = clamp(Math.max(25, parseInt(audioSpeedInput?.value || "100", 10)), 25, 400); + setAudioSpeedLabel(nextAudioSpeedPercent); + if (audioSpeedInput) audioSpeedInput.value = nextAudioSpeedPercent; + asset.audioSpeed = Math.max(0.25, nextAudioSpeedPercent / 100); + const nextAudioPitchPercent = clamp(Math.max(50, parseInt(audioPitchInput?.value || "100", 10)), 50, 200); + setAudioPitchLabel(nextAudioPitchPercent); + if (audioPitchInput) audioPitchInput.value = nextAudioPitchPercent; + asset.audioPitch = Math.max(0.5, nextAudioPitchPercent / 100); + const controller = ensureAudioController(asset); + applyAudioSettings(controller, asset); + schedulePersistTransform(asset); + drawAndList(); } function nudgeRotation(delta) { - const asset = getSelectedAsset(); - if (!asset) return; - const next = (asset.rotation || 0) + delta; - asset.rotation = next; - updateRenderState(asset); - persistTransform(asset); - drawAndList(); + const asset = getSelectedAsset(); + if (!asset) return; + const next = (asset.rotation || 0) + delta; + asset.rotation = next; + updateRenderState(asset); + persistTransform(asset); + drawAndList(); } function recenterSelectedAsset() { - const asset = getSelectedAsset(); - if (!asset) return; - const centerX = (canvas.width - asset.width) / 2; - const centerY = (canvas.height - asset.height) / 2; - asset.x = centerX; - asset.y = centerY; - updateRenderState(asset); - persistTransform(asset); - drawAndList(); + const asset = getSelectedAsset(); + if (!asset) return; + const centerX = (canvas.width - asset.width) / 2; + const centerY = (canvas.height - asset.height) / 2; + asset.x = centerX; + asset.y = centerY; + updateRenderState(asset); + persistTransform(asset); + drawAndList(); } function bringForward() { - const asset = getSelectedAsset(); - if (!asset) return; - const ordered = getAssetsByLayer(); - const index = ordered.findIndex((item) => item.id === asset.id); - if (index <= 0) return; - [ordered[index], ordered[index - 1]] = [ordered[index - 1], ordered[index]]; - applyLayerOrder(ordered); + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getAssetsByLayer(); + const index = ordered.findIndex((item) => item.id === asset.id); + if (index <= 0) return; + [ordered[index], ordered[index - 1]] = [ordered[index - 1], ordered[index]]; + applyLayerOrder(ordered); } function bringBackward() { - const asset = getSelectedAsset(); - if (!asset) return; - const ordered = getAssetsByLayer(); - const index = ordered.findIndex((item) => item.id === asset.id); - if (index === -1 || index === ordered.length - 1) return; - [ordered[index], ordered[index + 1]] = [ordered[index + 1], ordered[index]]; - applyLayerOrder(ordered); + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getAssetsByLayer(); + const index = ordered.findIndex((item) => item.id === asset.id); + if (index === -1 || index === ordered.length - 1) return; + [ordered[index], ordered[index + 1]] = [ordered[index + 1], ordered[index]]; + applyLayerOrder(ordered); } function bringToFront() { - const asset = getSelectedAsset(); - if (!asset) return; - const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); - ordered.unshift(asset); - applyLayerOrder(ordered); + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); + ordered.unshift(asset); + applyLayerOrder(ordered); } function sendToBack() { - const asset = getSelectedAsset(); - if (!asset) return; - const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); - ordered.push(asset); - applyLayerOrder(ordered); + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); + ordered.push(asset); + applyLayerOrder(ordered); } function applyLayerOrder(ordered) { - const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id)); - layerOrder = newOrder; - const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean); - changed.forEach((item) => updateRenderState(item)); - changed.forEach((item) => schedulePersistTransform(item, true)); - drawAndList(); + const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id)); + layerOrder = newOrder; + const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean); + changed.forEach((item) => updateRenderState(item)); + changed.forEach((item) => schedulePersistTransform(item, true)); + drawAndList(); } function getAssetAspectRatio(asset) { - const media = ensureMedia(asset); - if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) { - return media.videoWidth / media.videoHeight; - } - if (!isVideoElement(media) && media?.naturalWidth && media?.naturalHeight) { - return media.naturalWidth / media.naturalHeight; - } - if (asset.width && asset.height) { - return asset.width / asset.height; - } - return null; + const media = ensureMedia(asset); + if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) { + return media.videoWidth / media.videoHeight; + } + if (!isVideoElement(media) && media?.naturalWidth && media?.naturalHeight) { + return media.naturalWidth / media.naturalHeight; + } + if (asset.width && asset.height) { + return asset.width / asset.height; + } + return null; } function formatAspectRatioLabel(asset) { - if (isAudioAsset(asset)) { - return ''; - } - const ratio = getAssetAspectRatio(asset); - if (!ratio) { - return ''; - } - const normalized = ratio >= 1 ? `${ratio.toFixed(2)}:1` : `1:${(1 / ratio).toFixed(2)}`; - return `AR ${normalized}`; + if (isAudioAsset(asset)) { + return ""; + } + const ratio = getAssetAspectRatio(asset); + if (!ratio) { + return ""; + } + const normalized = ratio >= 1 ? `${ratio.toFixed(2)}:1` : `1:${(1 / ratio).toFixed(2)}`; + return `AR ${normalized}`; } function setAspectLock(assetId, locked) { - aspectLockState.set(assetId, locked); + aspectLockState.set(assetId, locked); } function isAspectLocked(assetId) { - return aspectLockState.has(assetId) ? aspectLockState.get(assetId) : true; + return aspectLockState.has(assetId) ? aspectLockState.get(assetId) : true; } function handleSizeInputChange(type) { - lastSizeInputChanged = type; - const asset = getSelectedAsset(); - if (!asset) { - return; - } - if (!isAspectLocked(asset.id)) { - commitSizeChange(); - return; - } - const ratio = getAssetAspectRatio(asset); - if (!ratio) { - return; - } - if (type === 'width' && widthInput && heightInput) { - const width = parseFloat(widthInput.value); - if (width > 0) { - heightInput.value = Math.round(width / ratio); - } - } else if (type === 'height' && widthInput && heightInput) { - const height = parseFloat(heightInput.value); - if (height > 0) { - widthInput.value = Math.round(height * ratio); - } - } + lastSizeInputChanged = type; + const asset = getSelectedAsset(); + if (!asset) { + return; + } + if (!isAspectLocked(asset.id)) { commitSizeChange(); + return; + } + const ratio = getAssetAspectRatio(asset); + if (!ratio) { + return; + } + if (type === "width" && widthInput && heightInput) { + const width = parseFloat(widthInput.value); + if (width > 0) { + heightInput.value = Math.round(width / ratio); + } + } else if (type === "height" && widthInput && heightInput) { + const height = parseFloat(heightInput.value); + if (height > 0) { + widthInput.value = Math.round(height * ratio); + } + } + commitSizeChange(); } function updateVisibility(asset, hidden) { - fetch(`/api/channels/${broadcaster}/assets/${asset.id}/visibility`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hidden }) - }).then((r) => { - if (!r.ok) { - throw new Error('Failed to update visibility'); - } - return r.json(); - }).then((updated) => { - storeAsset(updated); - let visibilityMessage = null; - if (updated.hidden) { - loopPlaybackState.set(updated.id, false); - stopAudio(updated.id); - showToast('Asset hidden from broadcast.', 'info'); - } else if (isAudioAsset(updated)) { - playAudioFromCanvas(updated, true); - visibilityMessage = 'Asset is now visible and active.'; - } else { - visibilityMessage = 'Asset is now visible.'; - } - if (visibilityMessage) { - showToast(visibilityMessage, 'success'); - } - updateRenderState(updated); - drawAndList(); - }).catch(() => showToast('Unable to change visibility right now.', 'error')); + fetch(`/api/channels/${broadcaster}/assets/${asset.id}/visibility`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hidden }), + }) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to update visibility"); + } + return r.json(); + }) + .then((updated) => { + storeAsset(updated); + let visibilityMessage = null; + if (updated.hidden) { + loopPlaybackState.set(updated.id, false); + stopAudio(updated.id); + showToast("Asset hidden from broadcast.", "info"); + } else if (isAudioAsset(updated)) { + playAudioFromCanvas(updated, true); + visibilityMessage = "Asset is now visible and active."; + } else { + visibilityMessage = "Asset is now visible."; + } + if (visibilityMessage) { + showToast(visibilityMessage, "success"); + } + updateRenderState(updated); + drawAndList(); + }) + .catch(() => showToast("Unable to change visibility right now.", "error")); } function triggerAudioPlayback(asset, shouldPlay = true) { - if (!asset) return Promise.resolve(); - return fetch(`/api/channels/${broadcaster}/assets/${asset.id}/play`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ play: shouldPlay }) - }).then((r) => r.json()).then((updated) => { - storeAsset(updated); - updateRenderState(updated); - return updated; + if (!asset) return Promise.resolve(); + return fetch(`/api/channels/${broadcaster}/assets/${asset.id}/play`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ play: shouldPlay }), + }) + .then((r) => r.json()) + .then((updated) => { + storeAsset(updated); + updateRenderState(updated); + return updated; }); } function deleteAsset(asset) { - fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }) - .then((response) => { - if (!response.ok) { - throw new Error('Failed to delete asset'); - } - clearMedia(asset.id); - assets.delete(asset.id); - renderStates.delete(asset.id); - layerOrder = layerOrder.filter((id) => id !== asset.id); - cancelPendingTransform(asset.id); - if (selectedAssetId === asset.id) { - selectedAssetId = null; - } - drawAndList(); - showToast('Asset deleted.', 'info'); - }) - .catch(() => showToast('Unable to delete asset. Please try again.', 'error')); + fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: "DELETE" }) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to delete asset"); + } + clearMedia(asset.id); + assets.delete(asset.id); + renderStates.delete(asset.id); + layerOrder = layerOrder.filter((id) => id !== asset.id); + cancelPendingTransform(asset.id); + if (selectedAssetId === asset.id) { + selectedAssetId = null; + } + drawAndList(); + showToast("Asset deleted.", "info"); + }) + .catch(() => showToast("Unable to delete asset. Please try again.", "error")); } function handleFileSelection(input) { - if (!input) return; - const hasFile = input.files && input.files.length; - const name = hasFile ? input.files[0].name : ''; - if (fileNameLabel) { - fileNameLabel.textContent = name || 'No file chosen'; - } - if (hasFile) { - uploadAsset(input.files[0]); - } + if (!input) return; + const hasFile = input.files && input.files.length; + const name = hasFile ? input.files[0].name : ""; + if (fileNameLabel) { + fileNameLabel.textContent = name || "No file chosen"; + } + if (hasFile) { + uploadAsset(input.files[0]); + } } function uploadAsset(file = null) { - const fileInput = document.getElementById('asset-file'); - const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null); - if (!selectedFile) { - showToast('Choose an image, GIF, video, or audio file to upload.', 'info'); - return; - } - if (selectedFile.size > UPLOAD_LIMIT_BYTES) { - showToast(`File is too large. Maximum upload size is ${UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, 'error'); - return; - } + const fileInput = document.getElementById("asset-file"); + const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null); + if (!selectedFile) { + showToast("Choose an image, GIF, video, or audio file to upload.", "info"); + return; + } + if (selectedFile.size > UPLOAD_LIMIT_BYTES) { + showToast(`File is too large. Maximum upload size is ${UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, "error"); + return; + } - const pendingId = addPendingUpload(selectedFile.name); - const data = new FormData(); - data.append('file', selectedFile); - if (fileNameLabel) { - fileNameLabel.textContent = 'Uploading...'; - } - fetch(`/api/channels/${broadcaster}/assets`, { - method: 'POST', - body: data - }).then((response) => { - if (!response.ok) { - throw new Error('Upload failed'); - } - if (fileInput) { - fileInput.value = ''; - handleFileSelection(fileInput); - } - showToast('Upload received. Processing asset...', 'success'); - updatePendingUpload(pendingId, { status: 'processing' }); - }).catch(() => { - if (fileNameLabel) { - fileNameLabel.textContent = 'Upload failed'; - } - removePendingUpload(pendingId); - showToast('Upload failed. Please try again with a supported file.', 'error'); + const pendingId = addPendingUpload(selectedFile.name); + const data = new FormData(); + data.append("file", selectedFile); + if (fileNameLabel) { + fileNameLabel.textContent = "Uploading..."; + } + fetch(`/api/channels/${broadcaster}/assets`, { + method: "POST", + body: data, + }) + .then((response) => { + if (!response.ok) { + throw new Error("Upload failed"); + } + if (fileInput) { + fileInput.value = ""; + handleFileSelection(fileInput); + } + showToast("Upload received. Processing asset...", "success"); + updatePendingUpload(pendingId, { status: "processing" }); + }) + .catch(() => { + if (fileNameLabel) { + fileNameLabel.textContent = "Upload failed"; + } + removePendingUpload(pendingId); + showToast("Upload failed. Please try again with a supported file.", "error"); }); } function getCanvasPoint(event) { - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - return { - x: (event.clientX - rect.left) * scaleX, - y: (event.clientY - rect.top) * scaleY - }; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY, + }; } function isPointOnAsset(asset, x, y) { - ctx.save(); - const halfWidth = asset.width / 2; - const halfHeight = asset.height / 2; - ctx.translate(asset.x + halfWidth, asset.y + halfHeight); - ctx.rotate(asset.rotation * Math.PI / 180); - const path = new Path2D(); - path.rect(-halfWidth, -halfHeight, asset.width, asset.height); - const hit = ctx.isPointInPath(path, x, y); - ctx.restore(); - return hit; + ctx.save(); + const halfWidth = asset.width / 2; + const halfHeight = asset.height / 2; + ctx.translate(asset.x + halfWidth, asset.y + halfHeight); + ctx.rotate((asset.rotation * Math.PI) / 180); + const path = new Path2D(); + path.rect(-halfWidth, -halfHeight, asset.width, asset.height); + const hit = ctx.isPointInPath(path, x, y); + ctx.restore(); + return hit; } function findAssetAtPoint(x, y) { - const ordered = getAssetsByLayer(); - return ordered.find((asset) => !isAudioAsset(asset) && isPointOnAsset(asset, x, y)) || null; + const ordered = getAssetsByLayer(); + return ordered.find((asset) => !isAudioAsset(asset) && isPointOnAsset(asset, x, y)) || null; } function persistTransform(asset, silent = false) { - cancelPendingTransform(asset.id); - const layer = getLayerValue(asset.id); - fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - x: asset.x, - y: asset.y, - width: asset.width, - height: asset.height, - rotation: asset.rotation, - speed: asset.speed, - layer, - zIndex: layer, - audioLoop: asset.audioLoop, - audioDelayMillis: asset.audioDelayMillis, - audioSpeed: asset.audioSpeed, - audioPitch: asset.audioPitch, - audioVolume: asset.audioVolume - }) - }).then((r) => { - if (!r.ok) { - throw new Error('Transform failed'); - } - return r.json(); - }).then((updated) => { - storeAsset(updated); - updateRenderState(updated); - if (!silent) { - drawAndList(); - } - }).catch(() => { - if (!silent) { - showToast('Unable to save changes. Please retry.', 'error'); - } + cancelPendingTransform(asset.id); + const layer = getLayerValue(asset.id); + fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + x: asset.x, + y: asset.y, + width: asset.width, + height: asset.height, + rotation: asset.rotation, + speed: asset.speed, + layer, + zIndex: layer, + audioLoop: asset.audioLoop, + audioDelayMillis: asset.audioDelayMillis, + audioSpeed: asset.audioSpeed, + audioPitch: asset.audioPitch, + audioVolume: asset.audioVolume, + }), + }) + .then((r) => { + if (!r.ok) { + throw new Error("Transform failed"); + } + return r.json(); + }) + .then((updated) => { + storeAsset(updated); + updateRenderState(updated); + if (!silent) { + drawAndList(); + } + }) + .catch(() => { + if (!silent) { + showToast("Unable to save changes. Please retry.", "error"); + } }); } -canvas.addEventListener('mousedown', (event) => { - const point = getCanvasPoint(event); - const current = getSelectedAsset(); - const handle = current ? hitHandle(current, point) : null; - if (current && handle) { - interactionState = handle === 'rotate' - ? { - mode: 'rotate', - assetId: current.id, - startAngle: angleFromCenter(current, point), - startRotation: current.rotation || 0 - } - : { - mode: 'resize', - assetId: current.id, - handle, - startLocal: pointerToLocal(current, point), - original: { ...current } - }; - canvas.style.cursor = cursorForHandle(handle); - drawAndList(); - return; - } - - const hit = findAssetAtPoint(point.x, point.y); - if (hit) { - selectedAssetId = hit.id; - updateRenderState(hit); - interactionState = { - mode: 'move', - assetId: hit.id, - offsetX: point.x - hit.x, - offsetY: point.y - hit.y - }; - canvas.style.cursor = 'grabbing'; - } else { - selectedAssetId = null; - interactionState = null; - canvas.style.cursor = 'default'; - } +canvas.addEventListener("mousedown", (event) => { + const point = getCanvasPoint(event); + const current = getSelectedAsset(); + const handle = current ? hitHandle(current, point) : null; + if (current && handle) { + interactionState = + handle === "rotate" + ? { + mode: "rotate", + assetId: current.id, + startAngle: angleFromCenter(current, point), + startRotation: current.rotation || 0, + } + : { + mode: "resize", + assetId: current.id, + handle, + startLocal: pointerToLocal(current, point), + original: { ...current }, + }; + canvas.style.cursor = cursorForHandle(handle); drawAndList(); + return; + } + + const hit = findAssetAtPoint(point.x, point.y); + if (hit) { + selectedAssetId = hit.id; + updateRenderState(hit); + interactionState = { + mode: "move", + assetId: hit.id, + offsetX: point.x - hit.x, + offsetY: point.y - hit.y, + }; + canvas.style.cursor = "grabbing"; + } else { + selectedAssetId = null; + interactionState = null; + canvas.style.cursor = "default"; + } + drawAndList(); }); -canvas.addEventListener('mousemove', (event) => { - const point = getCanvasPoint(event); - if (!interactionState) { - updateHoverCursor(point); - return; - } - const asset = assets.get(interactionState.assetId); - if (!asset) { - interactionState = null; - updateHoverCursor(point); - return; - } +canvas.addEventListener("mousemove", (event) => { + const point = getCanvasPoint(event); + if (!interactionState) { + updateHoverCursor(point); + return; + } + const asset = assets.get(interactionState.assetId); + if (!asset) { + interactionState = null; + updateHoverCursor(point); + return; + } - if (interactionState.mode === 'move') { - asset.x = point.x - interactionState.offsetX; - asset.y = point.y - interactionState.offsetY; - updateRenderState(asset); - canvas.style.cursor = 'grabbing'; - requestDraw(); - } else if (interactionState.mode === 'resize') { - resizeFromHandle(interactionState, point); - canvas.style.cursor = cursorForHandle(interactionState.handle); - } else if (interactionState.mode === 'rotate') { - const angle = angleFromCenter(asset, point); - asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle); - updateRenderState(asset); - canvas.style.cursor = 'grabbing'; - requestDraw(); - } + if (interactionState.mode === "move") { + asset.x = point.x - interactionState.offsetX; + asset.y = point.y - interactionState.offsetY; + updateRenderState(asset); + canvas.style.cursor = "grabbing"; + requestDraw(); + } else if (interactionState.mode === "resize") { + resizeFromHandle(interactionState, point); + canvas.style.cursor = cursorForHandle(interactionState.handle); + } else if (interactionState.mode === "rotate") { + const angle = angleFromCenter(asset, point); + asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle); + updateRenderState(asset); + canvas.style.cursor = "grabbing"; + requestDraw(); + } }); function endInteraction() { - if (!interactionState) { - return; - } - const asset = assets.get(interactionState.assetId); - interactionState = null; - canvas.style.cursor = 'default'; - drawAndList(); - if (asset) { - persistTransform(asset); - } + if (!interactionState) { + return; + } + const asset = assets.get(interactionState.assetId); + interactionState = null; + canvas.style.cursor = "default"; + drawAndList(); + if (asset) { + persistTransform(asset); + } } -canvas.addEventListener('mouseup', endInteraction); -canvas.addEventListener('mouseleave', endInteraction); +canvas.addEventListener("mouseup", endInteraction); +canvas.addEventListener("mouseleave", endInteraction); -window.addEventListener('resize', () => { - resizeCanvas(); +window.addEventListener("resize", () => { + resizeCanvas(); }); fetchCanvasSettings().finally(() => { - resizeCanvas(); - connect(); + resizeCanvas(); + connect(); }); diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 04c6a35..edc0d33 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -1,8 +1,9 @@ -const canvas = document.getElementById('broadcast-canvas'); +const canvas = document.getElementById("broadcast-canvas"); const obsBrowser = !!window.obsstudio; -const supportsAnimatedDecode = typeof ImageDecoder !== 'undefined' && typeof createImageBitmap === 'function' && !obsBrowser; -const canPlayProbe = document.createElement('video'); -const ctx = canvas.getContext('2d'); +const supportsAnimatedDecode = + typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !obsBrowser; +const canPlayProbe = document.createElement("video"); +const ctx = canvas.getContext("2d"); let canvasSettings = { width: 1920, height: 1080 }; canvas.width = canvasSettings.width; canvas.height = canvasSettings.height; @@ -23,854 +24,870 @@ let frameScheduled = false; let pendingDraw = false; let renderIntervalId = null; const pendingRemovals = new Set(); -const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart']; +const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"]; let layerOrder = []; audioUnlockEvents.forEach((eventName) => { - window.addEventListener(eventName, () => { - if (!pendingAudioUnlock.size) return; - pendingAudioUnlock.forEach((controller) => safePlay(controller)); - pendingAudioUnlock.clear(); - }); + window.addEventListener(eventName, () => { + if (!pendingAudioUnlock.size) return; + pendingAudioUnlock.forEach((controller) => safePlay(controller)); + pendingAudioUnlock.clear(); + }); }); -function ensureLayerPosition(assetId, placement = 'keep') { - const asset = assets.get(assetId); - if (asset && isAudioAsset(asset)) { - return; - } - const existingIndex = layerOrder.indexOf(assetId); - if (existingIndex !== -1 && placement === 'keep') { - return; - } - if (existingIndex !== -1) { - layerOrder.splice(existingIndex, 1); - } - if (placement === 'append') { - layerOrder.push(assetId); - } else { - layerOrder.unshift(assetId); - } - layerOrder = layerOrder.filter((id) => assets.has(id)); +function ensureLayerPosition(assetId, placement = "keep") { + const asset = assets.get(assetId); + if (asset && isAudioAsset(asset)) { + return; + } + const existingIndex = layerOrder.indexOf(assetId); + if (existingIndex !== -1 && placement === "keep") { + return; + } + if (existingIndex !== -1) { + layerOrder.splice(existingIndex, 1); + } + if (placement === "append") { + layerOrder.push(assetId); + } else { + layerOrder.unshift(assetId); + } + layerOrder = layerOrder.filter((id) => assets.has(id)); } function getLayerOrder() { - layerOrder = layerOrder.filter((id) => { - const asset = assets.get(id); - return asset && !isAudioAsset(asset); - }); - assets.forEach((asset, id) => { - if (isAudioAsset(asset)) { - return; - } - if (!layerOrder.includes(id)) { - layerOrder.unshift(id); - } - }); - return layerOrder; + layerOrder = layerOrder.filter((id) => { + const asset = assets.get(id); + return asset && !isAudioAsset(asset); + }); + assets.forEach((asset, id) => { + if (isAudioAsset(asset)) { + return; + } + if (!layerOrder.includes(id)) { + layerOrder.unshift(id); + } + }); + return layerOrder; } function getRenderOrder() { - return [...getLayerOrder()].reverse().map((id) => assets.get(id)).filter(Boolean); + return [...getLayerOrder()] + .reverse() + .map((id) => assets.get(id)) + .filter(Boolean); } function queueRemoval(assetId) { - if (assetId) { - pendingRemovals.add(assetId); - } + if (assetId) { + pendingRemovals.add(assetId); + } } function removeAsset(assetId) { - assets.delete(assetId); - layerOrder = layerOrder.filter((id) => id !== assetId); - clearMedia(assetId); - renderStates.delete(assetId); - visibilityStates.delete(assetId); + assets.delete(assetId); + layerOrder = layerOrder.filter((id) => id !== assetId); + clearMedia(assetId); + renderStates.delete(assetId); + visibilityStates.delete(assetId); } function flushPendingRemovals() { - if (!pendingRemovals.size) return; - pendingRemovals.forEach((id) => removeAsset(id)); - pendingRemovals.clear(); + if (!pendingRemovals.size) return; + pendingRemovals.forEach((id) => removeAsset(id)); + pendingRemovals.clear(); } function connect() { - const socket = new SockJS('/ws'); - const stompClient = Stomp.over(socket); - stompClient.connect({}, () => { - stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => { - const body = JSON.parse(payload.body); - handleEvent(body); - }); - fetch(`/api/channels/${broadcaster}/assets/visible`) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to load assets'); - } - return r.json(); - }) - .then(renderAssets) - .catch(() => showToast('Unable to load overlay assets. Retrying may help.', 'error')); + const socket = new SockJS("/ws"); + const stompClient = Stomp.over(socket); + stompClient.connect({}, () => { + stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => { + const body = JSON.parse(payload.body); + handleEvent(body); }); + fetch(`/api/channels/${broadcaster}/assets/visible`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load assets"); + } + return r.json(); + }) + .then(renderAssets) + .catch(() => showToast("Unable to load overlay assets. Retrying may help.", "error")); + }); } function renderAssets(list) { - layerOrder = []; - list.forEach((asset) => storeAsset(asset, 'append')); - draw(); + layerOrder = []; + list.forEach((asset) => storeAsset(asset, "append")); + draw(); } -function storeAsset(asset, placement = 'keep') { - if (!asset) return; - const wasExisting = assets.has(asset.id); - assets.set(asset.id, asset); - ensureLayerPosition(asset.id, placement); - if (!wasExisting && !visibilityStates.has(asset.id)) { - const initialAlpha = 0; // Fade in newly discovered assets - visibilityStates.set(asset.id, { alpha: initialAlpha, targetHidden: !!asset.hidden }); - } +function storeAsset(asset, placement = "keep") { + if (!asset) return; + const wasExisting = assets.has(asset.id); + assets.set(asset.id, asset); + ensureLayerPosition(asset.id, placement); + if (!wasExisting && !visibilityStates.has(asset.id)) { + const initialAlpha = 0; // Fade in newly discovered assets + visibilityStates.set(asset.id, { alpha: initialAlpha, targetHidden: !!asset.hidden }); + } } function fetchCanvasSettings() { - return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/canvas`) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to load canvas'); - } - return r.json(); - }) - .then((settings) => { - canvasSettings = settings; - resizeCanvas(); - }) - .catch(() => { - resizeCanvas(); - showToast('Using default canvas size. Unable to load saved settings.', 'warning'); - }); -} - -function resizeCanvas() { - const scale = Math.min(window.innerWidth / canvasSettings.width, window.innerHeight / canvasSettings.height); - const displayWidth = canvasSettings.width * scale; - const displayHeight = canvasSettings.height * scale; - canvas.width = canvasSettings.width; - canvas.height = canvasSettings.height; - canvas.style.width = `${displayWidth}px`; - canvas.style.height = `${displayHeight}px`; - canvas.style.left = `${(window.innerWidth - displayWidth) / 2}px`; - canvas.style.top = `${(window.innerHeight - displayHeight) / 2}px`; - draw(); -} - -function handleEvent(event) { - const assetId = event.assetId || event?.patch?.id || event?.payload?.id; - if (event.type === 'VISIBILITY') { - handleVisibilityEvent(event); - return; - } - if (event.type === 'DELETED') { - removeAsset(assetId); - } else if (event.patch) { - applyPatch(assetId, event.patch); - if (event.payload) { - const payload = normalizePayload(event.payload); - if (payload.hidden) { - hideAssetWithTransition(payload); - } else if (!assets.has(payload.id)) { - upsertVisibleAsset(payload, 'append'); - } - } - } else if (event.type === 'PLAY' && event.payload) { - const payload = normalizePayload(event.payload); - storeAsset(payload); - if (isAudioAsset(payload)) { - handleAudioPlay(payload, event.play !== false); - } - } else if (event.payload && !event.payload.hidden) { - const payload = normalizePayload(event.payload); - upsertVisibleAsset(payload); - } else if (event.payload && event.payload.hidden) { - hideAssetWithTransition(event.payload); - } - draw(); -} - -function normalizePayload(payload) { - return { ...payload }; -} - -function hideAssetWithTransition(asset) { - const payload = asset ? normalizePayload(asset) : null; - if (!payload?.id) { - return; - } - const existing = assets.get(payload.id); - if (!existing && (!Number.isFinite(payload.x) || !Number.isFinite(payload.y) || !Number.isFinite(payload.width) || !Number.isFinite(payload.height))) { - return; - } - const merged = normalizePayload({ ...(existing || {}), ...payload, hidden: true }); - storeAsset(merged); - stopAudio(payload.id); -} - -function upsertVisibleAsset(asset, placement = 'keep') { - const payload = asset ? normalizePayload(asset) : null; - if (!payload?.id) { - return; - } - const placementMode = assets.has(payload.id) ? 'keep' : placement; - storeAsset(payload, placementMode); - ensureMedia(payload); - if (isAudioAsset(payload)) { - playAudioImmediately(payload); - } -} - -function handleVisibilityEvent(event) { - const payload = event.payload ? normalizePayload(event.payload) : null; - const patch = event.patch; - const id = payload?.id || patch?.id || event.assetId; - - if (payload?.hidden || patch?.hidden) { - hideAssetWithTransition({ id, ...payload, ...patch }); - draw(); - return; - } - - if (payload) { - const placement = assets.has(payload.id) ? 'keep' : 'append'; - upsertVisibleAsset(payload, placement); - } - - if (patch && id) { - applyPatch(id, patch); - } - - draw(); -} - -function applyPatch(assetId, patch) { - if (!assetId || !patch) { - return; - } - const sanitizedPatch = Object.fromEntries( - Object.entries(patch).filter(([, value]) => value !== null && value !== undefined) - ); - const existing = assets.get(assetId); - if (!existing) { - return; - } - const merged = normalizePayload({ ...existing, ...sanitizedPatch }); - const isAudio = isAudioAsset(merged); - if (sanitizedPatch.hidden) { - hideAssetWithTransition(merged); - return; - } - const targetLayer = Number.isFinite(patch.layer) - ? patch.layer - : (Number.isFinite(patch.zIndex) ? patch.zIndex : null); - if (!isAudio && Number.isFinite(targetLayer)) { - const currentOrder = getLayerOrder().filter((id) => id !== assetId); - const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); - currentOrder.splice(insertIndex, 0, assetId); - layerOrder = currentOrder; - } - storeAsset(merged); - ensureMedia(merged); -} - -function draw() { - if (frameScheduled) { - pendingDraw = true; - return; - } - frameScheduled = true; - requestAnimationFrame((timestamp) => { - const elapsed = timestamp - lastRenderTime; - const delay = MIN_FRAME_TIME - elapsed; - const shouldRender = elapsed >= MIN_FRAME_TIME; - - if (shouldRender) { - lastRenderTime = timestamp; - renderFrame(); - } - - frameScheduled = false; - if (pendingDraw || !shouldRender) { - pendingDraw = false; - setTimeout(draw, Math.max(0, delay)); - } + return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/canvas`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load canvas"); + } + return r.json(); + }) + .then((settings) => { + canvasSettings = settings; + resizeCanvas(); + }) + .catch(() => { + resizeCanvas(); + showToast("Using default canvas size. Unable to load saved settings.", "warning"); }); } +function resizeCanvas() { + const scale = Math.min(window.innerWidth / canvasSettings.width, window.innerHeight / canvasSettings.height); + const displayWidth = canvasSettings.width * scale; + const displayHeight = canvasSettings.height * scale; + canvas.width = canvasSettings.width; + canvas.height = canvasSettings.height; + canvas.style.width = `${displayWidth}px`; + canvas.style.height = `${displayHeight}px`; + canvas.style.left = `${(window.innerWidth - displayWidth) / 2}px`; + canvas.style.top = `${(window.innerHeight - displayHeight) / 2}px`; + draw(); +} + +function handleEvent(event) { + const assetId = event.assetId || event?.patch?.id || event?.payload?.id; + if (event.type === "VISIBILITY") { + handleVisibilityEvent(event); + return; + } + if (event.type === "DELETED") { + removeAsset(assetId); + } else if (event.patch) { + applyPatch(assetId, event.patch); + if (event.payload) { + const payload = normalizePayload(event.payload); + if (payload.hidden) { + hideAssetWithTransition(payload); + } else if (!assets.has(payload.id)) { + upsertVisibleAsset(payload, "append"); + } + } + } else if (event.type === "PLAY" && event.payload) { + const payload = normalizePayload(event.payload); + storeAsset(payload); + if (isAudioAsset(payload)) { + handleAudioPlay(payload, event.play !== false); + } + } else if (event.payload && !event.payload.hidden) { + const payload = normalizePayload(event.payload); + upsertVisibleAsset(payload); + } else if (event.payload && event.payload.hidden) { + hideAssetWithTransition(event.payload); + } + draw(); +} + +function normalizePayload(payload) { + return { ...payload }; +} + +function hideAssetWithTransition(asset) { + const payload = asset ? normalizePayload(asset) : null; + if (!payload?.id) { + return; + } + const existing = assets.get(payload.id); + if ( + !existing && + (!Number.isFinite(payload.x) || + !Number.isFinite(payload.y) || + !Number.isFinite(payload.width) || + !Number.isFinite(payload.height)) + ) { + return; + } + const merged = normalizePayload({ ...(existing || {}), ...payload, hidden: true }); + storeAsset(merged); + stopAudio(payload.id); +} + +function upsertVisibleAsset(asset, placement = "keep") { + const payload = asset ? normalizePayload(asset) : null; + if (!payload?.id) { + return; + } + const placementMode = assets.has(payload.id) ? "keep" : placement; + storeAsset(payload, placementMode); + ensureMedia(payload); + if (isAudioAsset(payload)) { + playAudioImmediately(payload); + } +} + +function handleVisibilityEvent(event) { + const payload = event.payload ? normalizePayload(event.payload) : null; + const patch = event.patch; + const id = payload?.id || patch?.id || event.assetId; + + if (payload?.hidden || patch?.hidden) { + hideAssetWithTransition({ id, ...payload, ...patch }); + draw(); + return; + } + + if (payload) { + const placement = assets.has(payload.id) ? "keep" : "append"; + upsertVisibleAsset(payload, placement); + } + + if (patch && id) { + applyPatch(id, patch); + } + + draw(); +} + +function applyPatch(assetId, patch) { + if (!assetId || !patch) { + return; + } + const sanitizedPatch = Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== null && value !== undefined), + ); + const existing = assets.get(assetId); + if (!existing) { + return; + } + const merged = normalizePayload({ ...existing, ...sanitizedPatch }); + const isAudio = isAudioAsset(merged); + if (sanitizedPatch.hidden) { + hideAssetWithTransition(merged); + return; + } + const targetLayer = Number.isFinite(patch.layer) ? patch.layer : Number.isFinite(patch.zIndex) ? patch.zIndex : null; + if (!isAudio && Number.isFinite(targetLayer)) { + const currentOrder = getLayerOrder().filter((id) => id !== assetId); + const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); + currentOrder.splice(insertIndex, 0, assetId); + layerOrder = currentOrder; + } + storeAsset(merged); + ensureMedia(merged); +} + +function draw() { + if (frameScheduled) { + pendingDraw = true; + return; + } + frameScheduled = true; + requestAnimationFrame((timestamp) => { + const elapsed = timestamp - lastRenderTime; + const delay = MIN_FRAME_TIME - elapsed; + const shouldRender = elapsed >= MIN_FRAME_TIME; + + if (shouldRender) { + lastRenderTime = timestamp; + renderFrame(); + } + + frameScheduled = false; + if (pendingDraw || !shouldRender) { + pendingDraw = false; + setTimeout(draw, Math.max(0, delay)); + } + }); +} + function renderFrame() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - getRenderOrder().forEach(drawAsset); - flushPendingRemovals(); + ctx.clearRect(0, 0, canvas.width, canvas.height); + getRenderOrder().forEach(drawAsset); + flushPendingRemovals(); } function drawAsset(asset) { - const visibility = getVisibilityState(asset); - if (visibility.alpha <= VISIBILITY_THRESHOLD && asset.hidden) { - queueRemoval(asset.id); - return; - } - const renderState = smoothState(asset); - const halfWidth = renderState.width / 2; - const halfHeight = renderState.height / 2; - ctx.save(); - ctx.globalAlpha = Math.max(0, Math.min(1, visibility.alpha)); - ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); - ctx.rotate(renderState.rotation * Math.PI / 180); + const visibility = getVisibilityState(asset); + if (visibility.alpha <= VISIBILITY_THRESHOLD && asset.hidden) { + queueRemoval(asset.id); + return; + } + const renderState = smoothState(asset); + const halfWidth = renderState.width / 2; + const halfHeight = renderState.height / 2; + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(1, visibility.alpha)); + ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); + ctx.rotate((renderState.rotation * Math.PI) / 180); - if (isAudioAsset(asset)) { - if (!asset.hidden) { - autoStartAudio(asset); - } - ctx.restore(); - return; + if (isAudioAsset(asset)) { + if (!asset.hidden) { + autoStartAudio(asset); } - - const media = ensureMedia(asset); - const drawSource = media?.isAnimated ? media.bitmap : media; - const ready = isDrawable(media); - if (ready && drawSource) { - ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); - } - ctx.restore(); + return; + } + + const media = ensureMedia(asset); + const drawSource = media?.isAnimated ? media.bitmap : media; + const ready = isDrawable(media); + if (ready && drawSource) { + ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); + } + + ctx.restore(); } function getVisibilityState(asset) { - const current = visibilityStates.get(asset.id) || {}; - const targetAlpha = asset.hidden ? 0 : 1; - const startingAlpha = Number.isFinite(current.alpha) ? current.alpha : 0; - const factor = asset.hidden ? 0.18 : 0.2; - const nextAlpha = lerp(startingAlpha, targetAlpha, factor); - const state = { alpha: nextAlpha, targetHidden: !!asset.hidden }; - visibilityStates.set(asset.id, state); - return state; + const current = visibilityStates.get(asset.id) || {}; + const targetAlpha = asset.hidden ? 0 : 1; + const startingAlpha = Number.isFinite(current.alpha) ? current.alpha : 0; + const factor = asset.hidden ? 0.18 : 0.2; + const nextAlpha = lerp(startingAlpha, targetAlpha, factor); + const state = { alpha: nextAlpha, targetHidden: !!asset.hidden }; + visibilityStates.set(asset.id, state); + return state; } function smoothState(asset) { - const previous = renderStates.get(asset.id) || { ...asset }; - const factor = 0.15; - const next = { - x: lerp(previous.x, asset.x, factor), - y: lerp(previous.y, asset.y, factor), - width: lerp(previous.width, asset.width, factor), - height: lerp(previous.height, asset.height, factor), - rotation: smoothAngle(previous.rotation, asset.rotation, factor) - }; - renderStates.set(asset.id, next); - return next; + const previous = renderStates.get(asset.id) || { ...asset }; + const factor = 0.15; + const next = { + x: lerp(previous.x, asset.x, factor), + y: lerp(previous.y, asset.y, factor), + width: lerp(previous.width, asset.width, factor), + height: lerp(previous.height, asset.height, factor), + rotation: smoothAngle(previous.rotation, asset.rotation, factor), + }; + renderStates.set(asset.id, next); + return next; } function smoothAngle(current, target, factor) { - const delta = ((target - current + 180) % 360) - 180; - return current + delta * factor; + const delta = ((target - current + 180) % 360) - 180; + return current + delta * factor; } function lerp(a, b, t) { - return a + (b - a) * t; + return a + (b - a) * t; } function queueAudioForUnlock(controller) { - if (!controller) return; - pendingAudioUnlock.add(controller); + if (!controller) return; + pendingAudioUnlock.add(controller); } function safePlay(controller) { - if (!controller?.element) return; - const playPromise = controller.element.play(); - if (playPromise?.catch) { - playPromise.catch(() => queueAudioForUnlock(controller)); - } + if (!controller?.element) return; + const playPromise = controller.element.play(); + if (playPromise?.catch) { + playPromise.catch(() => queueAudioForUnlock(controller)); + } } function recordDuration(assetId, seconds) { - if (!Number.isFinite(seconds) || seconds <= 0) { - return; - } - const asset = assets.get(assetId); - if (!asset) { - return; - } - const nextMs = Math.round(seconds * 1000); - if (asset.durationMs === nextMs) { - return; - } - asset.durationMs = nextMs; + if (!Number.isFinite(seconds) || seconds <= 0) { + return; + } + const asset = assets.get(assetId); + if (!asset) { + return; + } + const nextMs = Math.round(seconds * 1000); + if (asset.durationMs === nextMs) { + return; + } + asset.durationMs = nextMs; } function isVideoAsset(asset) { - return asset?.mediaType?.startsWith('video/'); + return asset?.mediaType?.startsWith("video/"); } function isAudioAsset(asset) { - return asset?.mediaType?.startsWith('audio/'); + return asset?.mediaType?.startsWith("audio/"); } function isVideoElement(element) { - return element && element.tagName === 'VIDEO'; + return element && element.tagName === "VIDEO"; } function isGifAsset(asset) { - return asset?.mediaType?.toLowerCase() === 'image/gif'; + return asset?.mediaType?.toLowerCase() === "image/gif"; } function isDrawable(element) { - if (!element) { - return false; - } - if (element.isAnimated) { - return !!element.bitmap; - } - if (isVideoElement(element)) { - return element.readyState >= 2; - } - if (typeof ImageBitmap !== 'undefined' && element instanceof ImageBitmap) { - return true; - } - return !!element.complete; + if (!element) { + return false; + } + if (element.isAnimated) { + return !!element.bitmap; + } + if (isVideoElement(element)) { + return element.readyState >= 2; + } + if (typeof ImageBitmap !== "undefined" && element instanceof ImageBitmap) { + return true; + } + return !!element.complete; } function clearMedia(assetId) { - const element = mediaCache.get(assetId); - if (isVideoElement(element)) { - element.src = ''; - element.remove(); - } - mediaCache.delete(assetId); - const animated = animatedCache.get(assetId); - if (animated) { - animated.cancelled = true; - clearTimeout(animated.timeout); - animated.bitmap?.close?.(); - animated.decoder?.close?.(); - animatedCache.delete(assetId); - } - animationFailures.delete(assetId); - const cachedBlob = blobCache.get(assetId); - if (cachedBlob?.objectUrl) { - URL.revokeObjectURL(cachedBlob.objectUrl); - } - blobCache.delete(assetId); - const audio = audioControllers.get(assetId); - if (audio) { - if (audio.delayTimeout) { - clearTimeout(audio.delayTimeout); - } - audio.element.pause(); - audio.element.currentTime = 0; - audio.element.src = ''; - audio.element.remove(); - audioControllers.delete(assetId); + const element = mediaCache.get(assetId); + if (isVideoElement(element)) { + element.src = ""; + element.remove(); + } + mediaCache.delete(assetId); + const animated = animatedCache.get(assetId); + if (animated) { + animated.cancelled = true; + clearTimeout(animated.timeout); + animated.bitmap?.close?.(); + animated.decoder?.close?.(); + animatedCache.delete(assetId); + } + animationFailures.delete(assetId); + const cachedBlob = blobCache.get(assetId); + if (cachedBlob?.objectUrl) { + URL.revokeObjectURL(cachedBlob.objectUrl); + } + blobCache.delete(assetId); + const audio = audioControllers.get(assetId); + if (audio) { + if (audio.delayTimeout) { + clearTimeout(audio.delayTimeout); } + audio.element.pause(); + audio.element.currentTime = 0; + audio.element.src = ""; + audio.element.remove(); + audioControllers.delete(assetId); + } } function ensureAudioController(asset) { - const cached = audioControllers.get(asset.id); - if (cached && cached.src === asset.url) { - applyAudioSettings(cached, asset); - return cached; - } + const cached = audioControllers.get(asset.id); + if (cached && cached.src === asset.url) { + applyAudioSettings(cached, asset); + return cached; + } - if (cached) { - clearMedia(asset.id); - } + if (cached) { + clearMedia(asset.id); + } - const element = new Audio(asset.url); - element.autoplay = true; - element.preload = 'auto'; - element.controls = false; - element.addEventListener('loadedmetadata', () => recordDuration(asset.id, element.duration)); - const controller = { - id: asset.id, - src: asset.url, - element, - delayTimeout: null, - loopEnabled: false, - loopActive: true, - delayMs: 0, - baseDelayMs: 0 - }; - element.onended = () => handleAudioEnded(asset.id); - audioControllers.set(asset.id, controller); - applyAudioSettings(controller, asset, true); - return controller; + const element = new Audio(asset.url); + element.autoplay = true; + element.preload = "auto"; + element.controls = false; + element.addEventListener("loadedmetadata", () => recordDuration(asset.id, element.duration)); + const controller = { + id: asset.id, + src: asset.url, + element, + delayTimeout: null, + loopEnabled: false, + loopActive: true, + delayMs: 0, + baseDelayMs: 0, + }; + element.onended = () => handleAudioEnded(asset.id); + audioControllers.set(asset.id, controller); + applyAudioSettings(controller, asset, true); + return controller; } function applyAudioSettings(controller, asset, resetPosition = false) { - controller.loopEnabled = !!asset.audioLoop; - controller.loopActive = controller.loopEnabled && controller.loopActive !== false; - controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0); - controller.delayMs = controller.baseDelayMs; - applyAudioElementSettings(controller.element, asset); - if (resetPosition) { - controller.element.currentTime = 0; - controller.element.pause(); - } + controller.loopEnabled = !!asset.audioLoop; + controller.loopActive = controller.loopEnabled && controller.loopActive !== false; + controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0); + controller.delayMs = controller.baseDelayMs; + applyAudioElementSettings(controller.element, asset); + if (resetPosition) { + controller.element.currentTime = 0; + controller.element.pause(); + } } function applyAudioElementSettings(element, asset) { - const speed = Math.max(0.25, asset.audioSpeed || 1); - const pitch = Math.max(0.5, asset.audioPitch || 1); - element.playbackRate = speed * pitch; - const volume = Math.max(0, Math.min(2, asset.audioVolume ?? 1)); - element.volume = Math.min(volume, 1); + const speed = Math.max(0.25, asset.audioSpeed || 1); + const pitch = Math.max(0.5, asset.audioPitch || 1); + element.playbackRate = speed * pitch; + const volume = Math.max(0, Math.min(2, asset.audioVolume ?? 1)); + element.volume = Math.min(volume, 1); } function getAssetVolume(asset) { - return Math.max(0, Math.min(2, asset?.audioVolume ?? 1)); + return Math.max(0, Math.min(2, asset?.audioVolume ?? 1)); } function applyMediaVolume(element, asset) { - if (!element) return 1; - const volume = getAssetVolume(asset); - element.volume = Math.min(volume, 1); - return volume; + if (!element) return 1; + const volume = getAssetVolume(asset); + element.volume = Math.min(volume, 1); + return volume; } function handleAudioEnded(assetId) { - const controller = audioControllers.get(assetId); - if (!controller) return; - controller.element.currentTime = 0; - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - } - if (controller.loopEnabled && controller.loopActive) { - controller.delayTimeout = setTimeout(() => { - safePlay(controller); - }, controller.delayMs); - } else { - controller.element.pause(); - } + const controller = audioControllers.get(assetId); + if (!controller) return; + controller.element.currentTime = 0; + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + } + if (controller.loopEnabled && controller.loopActive) { + controller.delayTimeout = setTimeout(() => { + safePlay(controller); + }, controller.delayMs); + } else { + controller.element.pause(); + } } function stopAudio(assetId) { - const controller = audioControllers.get(assetId); - if (!controller) return; - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - } - controller.element.pause(); - controller.element.currentTime = 0; - controller.delayTimeout = null; - controller.delayMs = controller.baseDelayMs; - controller.loopActive = false; + const controller = audioControllers.get(assetId); + if (!controller) return; + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + } + controller.element.pause(); + controller.element.currentTime = 0; + controller.delayTimeout = null; + controller.delayMs = controller.baseDelayMs; + controller.loopActive = false; } function playAudioImmediately(asset) { - const controller = ensureAudioController(asset); - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - controller.delayTimeout = null; - } - controller.element.currentTime = 0; - const originalDelay = controller.delayMs; - controller.delayMs = 0; - safePlay(controller); - controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; + const controller = ensureAudioController(asset); + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + controller.delayTimeout = null; + } + controller.element.currentTime = 0; + const originalDelay = controller.delayMs; + controller.delayMs = 0; + safePlay(controller); + controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; } function playOverlappingAudio(asset) { - const temp = new Audio(asset.url); - temp.autoplay = true; - temp.preload = 'auto'; - temp.controls = false; - applyAudioElementSettings(temp, asset); - const controller = { element: temp }; - temp.onended = () => { - temp.remove(); - }; - safePlay(controller); + const temp = new Audio(asset.url); + temp.autoplay = true; + temp.preload = "auto"; + temp.controls = false; + applyAudioElementSettings(temp, asset); + const controller = { element: temp }; + temp.onended = () => { + temp.remove(); + }; + safePlay(controller); } function handleAudioPlay(asset, shouldPlay) { - const controller = ensureAudioController(asset); - controller.loopActive = !!shouldPlay; - if (!shouldPlay) { - stopAudio(asset.id); - return; - } - if (asset.audioLoop) { - controller.delayMs = controller.baseDelayMs; - safePlay(controller); - } else { - playOverlappingAudio(asset); - } + const controller = ensureAudioController(asset); + controller.loopActive = !!shouldPlay; + if (!shouldPlay) { + stopAudio(asset.id); + return; + } + if (asset.audioLoop) { + controller.delayMs = controller.baseDelayMs; + safePlay(controller); + } else { + playOverlappingAudio(asset); + } } function autoStartAudio(asset) { - if (!isAudioAsset(asset) || asset.hidden) { - return; - } - const controller = ensureAudioController(asset); - if (!controller.loopEnabled || !controller.loopActive) { - return; - } - if (!controller.element.paused && !controller.element.ended) { - return; - } - if (controller.delayTimeout) { - return; - } - controller.delayTimeout = setTimeout(() => { - safePlay(controller); - }, controller.delayMs); + if (!isAudioAsset(asset) || asset.hidden) { + return; + } + const controller = ensureAudioController(asset); + if (!controller.loopEnabled || !controller.loopActive) { + return; + } + if (!controller.element.paused && !controller.element.ended) { + return; + } + if (controller.delayTimeout) { + return; + } + controller.delayTimeout = setTimeout(() => { + safePlay(controller); + }, controller.delayMs); } function ensureMedia(asset) { - const cached = mediaCache.get(asset.id); - const cachedSource = getCachedSource(cached); - if (cached && cachedSource !== asset.url) { - clearMedia(asset.id); - } - if (cached && cachedSource === asset.url) { - applyMediaSettings(cached, asset); - return cached; - } + const cached = mediaCache.get(asset.id); + const cachedSource = getCachedSource(cached); + if (cached && cachedSource !== asset.url) { + clearMedia(asset.id); + } + if (cached && cachedSource === asset.url) { + applyMediaSettings(cached, asset); + return cached; + } - if (isAudioAsset(asset)) { - ensureAudioController(asset); - mediaCache.delete(asset.id); - return null; - } + if (isAudioAsset(asset)) { + ensureAudioController(asset); + mediaCache.delete(asset.id); + return null; + } - if (isGifAsset(asset) && supportsAnimatedDecode) { - const animated = ensureAnimatedImage(asset); - if (animated) { - mediaCache.set(asset.id, animated); - return animated; - } + if (isGifAsset(asset) && supportsAnimatedDecode) { + const animated = ensureAnimatedImage(asset); + if (animated) { + mediaCache.set(asset.id, animated); + return animated; } + } - const element = isVideoAsset(asset) ? document.createElement('video') : new Image(); - element.dataset.sourceUrl = asset.url; - element.crossOrigin = 'anonymous'; - if (isVideoElement(element)) { - if (!canPlayVideoType(asset.mediaType)) { - return null; - } - element.loop = true; - element.playsInline = true; - element.autoplay = true; - element.controls = false; - element.onloadeddata = draw; - element.onloadedmetadata = () => recordDuration(asset.id, element.duration); - element.preload = 'auto'; - element.addEventListener('error', () => clearMedia(asset.id)); - applyMediaVolume(element, asset); - element.muted = true; - setVideoSource(element, asset); - } else { - element.onload = draw; - element.src = asset.url; + const element = isVideoAsset(asset) ? document.createElement("video") : new Image(); + element.dataset.sourceUrl = asset.url; + element.crossOrigin = "anonymous"; + if (isVideoElement(element)) { + if (!canPlayVideoType(asset.mediaType)) { + return null; } - mediaCache.set(asset.id, element); - return element; + element.loop = true; + element.playsInline = true; + element.autoplay = true; + element.controls = false; + element.onloadeddata = draw; + element.onloadedmetadata = () => recordDuration(asset.id, element.duration); + element.preload = "auto"; + element.addEventListener("error", () => clearMedia(asset.id)); + applyMediaVolume(element, asset); + element.muted = true; + setVideoSource(element, asset); + } else { + element.onload = draw; + element.src = asset.url; + } + mediaCache.set(asset.id, element); + return element; } function ensureAnimatedImage(asset) { - const failedAt = animationFailures.get(asset.id); - if (failedAt && Date.now() - failedAt < 15000) { + const failedAt = animationFailures.get(asset.id); + if (failedAt && Date.now() - failedAt < 15000) { + return null; + } + const cached = animatedCache.get(asset.id); + if (cached && cached.url === asset.url) { + return cached; + } + + animationFailures.delete(asset.id); + + if (cached) { + clearMedia(asset.id); + } + + const controller = { + id: asset.id, + url: asset.url, + src: asset.url, + decoder: null, + bitmap: null, + timeout: null, + cancelled: false, + isAnimated: true, + }; + + fetchAssetBlob(asset) + .then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" })) + .then((decoder) => { + if (controller.cancelled) { + decoder.close?.(); return null; - } - const cached = animatedCache.get(asset.id); - if (cached && cached.url === asset.url) { - return cached; - } + } + controller.decoder = decoder; + scheduleNextFrame(controller); + return controller; + }) + .catch(() => { + animatedCache.delete(asset.id); + animationFailures.set(asset.id, Date.now()); + }); - animationFailures.delete(asset.id); - - if (cached) { - clearMedia(asset.id); - } - - const controller = { - id: asset.id, - url: asset.url, - src: asset.url, - decoder: null, - bitmap: null, - timeout: null, - cancelled: false, - isAnimated: true - }; - - fetchAssetBlob(asset) - .then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' })) - .then((decoder) => { - if (controller.cancelled) { - decoder.close?.(); - return null; - } - controller.decoder = decoder; - scheduleNextFrame(controller); - return controller; - }) - .catch(() => { - animatedCache.delete(asset.id); - animationFailures.set(asset.id, Date.now()); - }); - - animatedCache.set(asset.id, controller); - return controller; + animatedCache.set(asset.id, controller); + return controller; } function fetchAssetBlob(asset) { - const cached = blobCache.get(asset.id); - if (cached && cached.url === asset.url && cached.blob) { - return Promise.resolve(cached.blob); - } - if (cached && cached.url === asset.url && cached.pending) { - return cached.pending; - } + const cached = blobCache.get(asset.id); + if (cached && cached.url === asset.url && cached.blob) { + return Promise.resolve(cached.blob); + } + if (cached && cached.url === asset.url && cached.pending) { + return cached.pending; + } - const pending = fetch(asset.url) - .then((r) => r.blob()) - .then((blob) => { - const previous = blobCache.get(asset.id); - const existingUrl = previous?.url === asset.url ? previous.objectUrl : null; - const objectUrl = existingUrl || URL.createObjectURL(blob); - blobCache.set(asset.id, { url: asset.url, blob, objectUrl }); - return blob; - }); - blobCache.set(asset.id, { url: asset.url, pending }); - return pending; + const pending = fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => { + const previous = blobCache.get(asset.id); + const existingUrl = previous?.url === asset.url ? previous.objectUrl : null; + const objectUrl = existingUrl || URL.createObjectURL(blob); + blobCache.set(asset.id, { url: asset.url, blob, objectUrl }); + return blob; + }); + blobCache.set(asset.id, { url: asset.url, pending }); + return pending; } function setVideoSource(element, asset) { - if (!shouldUseBlobUrl(asset)) { - applyVideoSource(element, asset.url, asset); - return; - } + if (!shouldUseBlobUrl(asset)) { + applyVideoSource(element, asset.url, asset); + return; + } - const cached = blobCache.get(asset.id); - if (cached?.url === asset.url && cached.objectUrl) { - applyVideoSource(element, cached.objectUrl, asset); - return; - } + const cached = blobCache.get(asset.id); + if (cached?.url === asset.url && cached.objectUrl) { + applyVideoSource(element, cached.objectUrl, asset); + return; + } - fetchAssetBlob(asset).then(() => { - const next = blobCache.get(asset.id); - if (next?.url !== asset.url || !next.objectUrl) { - return; - } - applyVideoSource(element, next.objectUrl, asset); - }).catch(() => { }); + fetchAssetBlob(asset) + .then(() => { + const next = blobCache.get(asset.id); + if (next?.url !== asset.url || !next.objectUrl) { + return; + } + applyVideoSource(element, next.objectUrl, asset); + }) + .catch(() => {}); } function applyVideoSource(element, objectUrl, asset) { - element.src = objectUrl; - startVideoPlayback(element, asset); + element.src = objectUrl; + startVideoPlayback(element, asset); } function shouldUseBlobUrl(asset) { - return !obsBrowser && asset?.mediaType && canPlayVideoType(asset.mediaType); + return !obsBrowser && asset?.mediaType && canPlayVideoType(asset.mediaType); } function canPlayVideoType(mediaType) { - if (!mediaType) { - return true; - } - const support = canPlayProbe.canPlayType(mediaType); - return support === 'probably' || support === 'maybe'; + if (!mediaType) { + return true; + } + const support = canPlayProbe.canPlayType(mediaType); + return support === "probably" || support === "maybe"; } function getCachedSource(element) { - return element?.dataset?.sourceUrl || element?.src; + return element?.dataset?.sourceUrl || element?.src; } function scheduleNextFrame(controller) { - if (controller.cancelled || !controller.decoder) { + if (controller.cancelled || !controller.decoder) { + return; + } + controller.decoder + .decode() + .then(({ image, complete }) => { + if (controller.cancelled) { + image.close?.(); return; - } - controller.decoder.decode().then(({ image, complete }) => { - if (controller.cancelled) { - image.close?.(); - return; - } - controller.bitmap?.close?.(); - createImageBitmap(image) - .then((bitmap) => { - controller.bitmap = bitmap; - draw(); - }) - .finally(() => image.close?.()); + } + controller.bitmap?.close?.(); + createImageBitmap(image) + .then((bitmap) => { + controller.bitmap = bitmap; + draw(); + }) + .finally(() => image.close?.()); - const durationMicros = image.duration || 0; - const delay = durationMicros > 0 ? durationMicros / 1000 : 100; - const hasMore = !complete; - controller.timeout = setTimeout(() => { - if (controller.cancelled) { - return; - } - if (hasMore) { - scheduleNextFrame(controller); - } else { - controller.decoder.reset(); - scheduleNextFrame(controller); - } - }, delay); - }).catch(() => { - // If decoding fails, clear animated cache so static fallback is used next render - animatedCache.delete(controller.id); - animationFailures.set(controller.id, Date.now()); + const durationMicros = image.duration || 0; + const delay = durationMicros > 0 ? durationMicros / 1000 : 100; + const hasMore = !complete; + controller.timeout = setTimeout(() => { + if (controller.cancelled) { + return; + } + if (hasMore) { + scheduleNextFrame(controller); + } else { + controller.decoder.reset(); + scheduleNextFrame(controller); + } + }, delay); + }) + .catch(() => { + // If decoding fails, clear animated cache so static fallback is used next render + animatedCache.delete(controller.id); + animationFailures.set(controller.id, Date.now()); }); } function applyMediaSettings(element, asset) { - if (!isVideoElement(element)) { - return; - } - startVideoPlayback(element, asset); + if (!isVideoElement(element)) { + return; + } + startVideoPlayback(element, asset); } function startVideoPlayback(element, asset) { - const nextSpeed = asset.speed ?? 1; - const effectiveSpeed = Math.max(nextSpeed, 0.01); - if (element.playbackRate !== effectiveSpeed) { - element.playbackRate = effectiveSpeed; - } - const volume = applyMediaVolume(element, asset); - const shouldUnmute = volume > 0; - element.muted = true; + const nextSpeed = asset.speed ?? 1; + const effectiveSpeed = Math.max(nextSpeed, 0.01); + if (element.playbackRate !== effectiveSpeed) { + element.playbackRate = effectiveSpeed; + } + const volume = applyMediaVolume(element, asset); + const shouldUnmute = volume > 0; + element.muted = true; - if (effectiveSpeed === 0) { - element.pause(); - return; - } + if (effectiveSpeed === 0) { + element.pause(); + return; + } - element.play(); + element.play(); - if (shouldUnmute) { - if (!element.paused && element.readyState >= 2) { - element.muted = false; - } else { - element.addEventListener('playing', () => { - element.muted = false; - }, { once: true }); - } + if (shouldUnmute) { + if (!element.paused && element.readyState >= 2) { + element.muted = false; + } else { + element.addEventListener( + "playing", + () => { + element.muted = false; + }, + { once: true }, + ); } + } } function startRenderLoop() { - if (renderIntervalId) { - return; - } - renderIntervalId = setInterval(() => { - draw(); - }, MIN_FRAME_TIME); + if (renderIntervalId) { + return; + } + renderIntervalId = setInterval(() => { + draw(); + }, MIN_FRAME_TIME); } -window.addEventListener('resize', () => { - resizeCanvas(); +window.addEventListener("resize", () => { + resizeCanvas(); }); fetchCanvasSettings().finally(() => { - resizeCanvas(); - startRenderLoop(); - connect(); + resizeCanvas(); + startRenderLoop(); + connect(); }); diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index cde8d88..82857e4 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -1,226 +1,228 @@ function buildIdentity(admin) { - const identity = document.createElement('div'); - identity.className = 'identity-row'; + const identity = document.createElement("div"); + identity.className = "identity-row"; - const avatar = document.createElement(admin.avatarUrl ? 'img' : 'div'); - avatar.className = 'avatar'; - if (admin.avatarUrl) { - avatar.src = admin.avatarUrl; - avatar.alt = `${admin.displayName || admin.login} avatar`; - } else { - avatar.classList.add('avatar-fallback'); - avatar.textContent = (admin.displayName || admin.login || '?').charAt(0).toUpperCase(); - } + const avatar = document.createElement(admin.avatarUrl ? "img" : "div"); + avatar.className = "avatar"; + if (admin.avatarUrl) { + avatar.src = admin.avatarUrl; + avatar.alt = `${admin.displayName || admin.login} avatar`; + } else { + avatar.classList.add("avatar-fallback"); + avatar.textContent = (admin.displayName || admin.login || "?").charAt(0).toUpperCase(); + } - const details = document.createElement('div'); - details.className = 'identity-text'; - const title = document.createElement('p'); - title.className = 'list-title'; - title.textContent = admin.displayName || admin.login; - const subtitle = document.createElement('p'); - subtitle.className = 'muted'; - subtitle.textContent = `@${admin.login}`; + const details = document.createElement("div"); + details.className = "identity-text"; + const title = document.createElement("p"); + title.className = "list-title"; + title.textContent = admin.displayName || admin.login; + const subtitle = document.createElement("p"); + subtitle.className = "muted"; + subtitle.textContent = `@${admin.login}`; - details.appendChild(title); - details.appendChild(subtitle); - identity.appendChild(avatar); - identity.appendChild(details); - return identity; + details.appendChild(title); + details.appendChild(subtitle); + identity.appendChild(avatar); + identity.appendChild(details); + return identity; } function renderAdmins(list) { - const adminList = document.getElementById('admin-list'); - if (!adminList) return; - adminList.innerHTML = ''; - if (!list || list.length === 0) { - const empty = document.createElement('li'); - empty.textContent = 'No channel admins yet'; - adminList.appendChild(empty); - return; - } + const adminList = document.getElementById("admin-list"); + if (!adminList) return; + adminList.innerHTML = ""; + if (!list || list.length === 0) { + const empty = document.createElement("li"); + empty.textContent = "No channel admins yet"; + adminList.appendChild(empty); + return; + } - list.forEach((admin) => { - const li = document.createElement('li'); - li.className = 'stacked-list-item'; + list.forEach((admin) => { + const li = document.createElement("li"); + li.className = "stacked-list-item"; - li.appendChild(buildIdentity(admin)); + li.appendChild(buildIdentity(admin)); - const actions = document.createElement('div'); - actions.className = 'actions'; + const actions = document.createElement("div"); + actions.className = "actions"; - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'secondary'; - removeBtn.textContent = 'Remove'; - removeBtn.addEventListener('click', () => removeAdmin(admin.login)); + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "secondary"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", () => removeAdmin(admin.login)); - actions.appendChild(removeBtn); - li.appendChild(actions); - adminList.appendChild(li); - }); + actions.appendChild(removeBtn); + li.appendChild(actions); + adminList.appendChild(li); + }); } function renderSuggestedAdmins(list) { - const suggestionList = document.getElementById('admin-suggestions'); - if (!suggestionList) return; + const suggestionList = document.getElementById("admin-suggestions"); + if (!suggestionList) return; - suggestionList.innerHTML = ''; - if (!list || list.length === 0) { - const empty = document.createElement('li'); - empty.className = 'stacked-list-item'; - empty.textContent = 'No moderator suggestions right now'; - suggestionList.appendChild(empty); - return; - } + suggestionList.innerHTML = ""; + if (!list || list.length === 0) { + const empty = document.createElement("li"); + empty.className = "stacked-list-item"; + empty.textContent = "No moderator suggestions right now"; + suggestionList.appendChild(empty); + return; + } - list.forEach((admin) => { - const li = document.createElement('li'); - li.className = 'stacked-list-item'; + list.forEach((admin) => { + const li = document.createElement("li"); + li.className = "stacked-list-item"; - li.appendChild(buildIdentity(admin)); + li.appendChild(buildIdentity(admin)); - const actions = document.createElement('div'); - actions.className = 'actions'; + const actions = document.createElement("div"); + actions.className = "actions"; - const addBtn = document.createElement('button'); - addBtn.type = 'button'; - addBtn.className = 'ghost'; - addBtn.textContent = 'Add as admin'; - addBtn.addEventListener('click', () => addAdmin(admin.login)); + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "ghost"; + addBtn.textContent = "Add as admin"; + addBtn.addEventListener("click", () => addAdmin(admin.login)); - actions.appendChild(addBtn); - li.appendChild(actions); - suggestionList.appendChild(li); - }); + actions.appendChild(addBtn); + li.appendChild(actions); + suggestionList.appendChild(li); + }); } function fetchSuggestedAdmins() { - fetch(`/api/channels/${broadcaster}/admins/suggestions`) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to load admin suggestions'); - } - return r.json(); - }) - .then(renderSuggestedAdmins) - .catch(() => { - renderSuggestedAdmins([]); - }); + fetch(`/api/channels/${broadcaster}/admins/suggestions`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load admin suggestions"); + } + return r.json(); + }) + .then(renderSuggestedAdmins) + .catch(() => { + renderSuggestedAdmins([]); + }); } function fetchAdmins() { - fetch(`/api/channels/${broadcaster}/admins`) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to load admins'); - } - return r.json(); - }) - .then(renderAdmins) - .catch(() => { - renderAdmins([]); - showToast('Unable to load admins right now. Please try again.', 'error'); - }); + fetch(`/api/channels/${broadcaster}/admins`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load admins"); + } + return r.json(); + }) + .then(renderAdmins) + .catch(() => { + renderAdmins([]); + showToast("Unable to load admins right now. Please try again.", "error"); + }); } function removeAdmin(username) { - if (!username) return; - fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, { - method: 'DELETE' - }).then((response) => { - if (!response.ok) { - throw new Error(); - } - fetchAdmins(); - fetchSuggestedAdmins(); - }).catch(() => { - showToast('Failed to remove admin. Please retry.', 'error'); + if (!username) return; + fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, { + method: "DELETE", + }) + .then((response) => { + if (!response.ok) { + throw new Error(); + } + fetchAdmins(); + fetchSuggestedAdmins(); + }) + .catch(() => { + showToast("Failed to remove admin. Please retry.", "error"); }); } function addAdmin(usernameFromAction) { - const input = document.getElementById('new-admin'); - const username = (usernameFromAction || input?.value || '').trim(); - if (!username) { - showToast('Enter a Twitch username to add as an admin.', 'info'); - return; - } + const input = document.getElementById("new-admin"); + const username = (usernameFromAction || input?.value || "").trim(); + if (!username) { + showToast("Enter a Twitch username to add as an admin.", "info"); + return; + } - fetch(`/api/channels/${broadcaster}/admins`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username }) + fetch(`/api/channels/${broadcaster}/admins`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }) + .then((response) => { + if (!response.ok) { + throw new Error("Add admin failed"); + } + if (input) { + input.value = ""; + } + showToast(`Added @${username} as an admin.`, "success"); + fetchAdmins(); + fetchSuggestedAdmins(); }) - .then((response) => { - if (!response.ok) { - throw new Error('Add admin failed'); - } - if (input) { - input.value = ''; - } - showToast(`Added @${username} as an admin.`, 'success'); - fetchAdmins(); - fetchSuggestedAdmins(); - }) - .catch(() => showToast('Unable to add admin right now. Please try again.', 'error')); + .catch(() => showToast("Unable to add admin right now. Please try again.", "error")); } function renderCanvasSettings(settings) { - const widthInput = document.getElementById('canvas-width'); - const heightInput = document.getElementById('canvas-height'); - if (widthInput) widthInput.value = Math.round(settings.width); - if (heightInput) heightInput.value = Math.round(settings.height); + const widthInput = document.getElementById("canvas-width"); + const heightInput = document.getElementById("canvas-height"); + if (widthInput) widthInput.value = Math.round(settings.width); + if (heightInput) heightInput.value = Math.round(settings.height); } function fetchCanvasSettings() { - fetch(`/api/channels/${broadcaster}/canvas`) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to load canvas settings'); - } - return r.json(); - }) - .then(renderCanvasSettings) - .catch(() => { - renderCanvasSettings({ width: 1920, height: 1080 }); - showToast('Using default canvas size. Unable to load saved settings.', 'warning'); - }); + fetch(`/api/channels/${broadcaster}/canvas`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load canvas settings"); + } + return r.json(); + }) + .then(renderCanvasSettings) + .catch(() => { + renderCanvasSettings({ width: 1920, height: 1080 }); + showToast("Using default canvas size. Unable to load saved settings.", "warning"); + }); } function saveCanvasSettings() { - const widthInput = document.getElementById('canvas-width'); - const heightInput = document.getElementById('canvas-height'); - const status = document.getElementById('canvas-status'); - const width = parseFloat(widthInput?.value) || 0; - const height = parseFloat(heightInput?.value) || 0; - if (width <= 0 || height <= 0) { - showToast('Please enter a valid width and height.', 'info'); - return; - } - if (status) status.textContent = 'Saving...'; - fetch(`/api/channels/${broadcaster}/canvas`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ width, height }) + const widthInput = document.getElementById("canvas-width"); + const heightInput = document.getElementById("canvas-height"); + const status = document.getElementById("canvas-status"); + const width = parseFloat(widthInput?.value) || 0; + const height = parseFloat(heightInput?.value) || 0; + if (width <= 0 || height <= 0) { + showToast("Please enter a valid width and height.", "info"); + return; + } + if (status) status.textContent = "Saving..."; + fetch(`/api/channels/${broadcaster}/canvas`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ width, height }), + }) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to save canvas"); + } + return r.json(); }) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to save canvas'); - } - return r.json(); - }) - .then((settings) => { - renderCanvasSettings(settings); - if (status) status.textContent = 'Saved.'; - showToast('Canvas size saved successfully.', 'success'); - setTimeout(() => { - if (status) status.textContent = ''; - }, 2000); - }) - .catch(() => { - if (status) status.textContent = 'Unable to save right now.'; - showToast('Unable to save canvas size. Please retry.', 'error'); - }); + .then((settings) => { + renderCanvasSettings(settings); + if (status) status.textContent = "Saved."; + showToast("Canvas size saved successfully.", "success"); + setTimeout(() => { + if (status) status.textContent = ""; + }, 2000); + }) + .catch(() => { + if (status) status.textContent = "Unable to save right now."; + showToast("Unable to save canvas size. Please retry.", "error"); + }); } fetchAdmins(); diff --git a/src/main/resources/static/js/downloads.js b/src/main/resources/static/js/downloads.js index f4e324a..13c1185 100644 --- a/src/main/resources/static/js/downloads.js +++ b/src/main/resources/static/js/downloads.js @@ -1,40 +1,40 @@ function detectPlatform() { - const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || '').toLowerCase(); - const userAgent = (navigator.userAgent || '').toLowerCase(); - const platformString = `${navigatorPlatform} ${userAgent}`; + const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || "").toLowerCase(); + const userAgent = (navigator.userAgent || "").toLowerCase(); + const platformString = `${navigatorPlatform} ${userAgent}`; - if (platformString.includes('mac') || platformString.includes('darwin')) { - return 'mac'; - } - if (platformString.includes('win')) { - return 'windows'; - } - if (platformString.includes('linux')) { - return 'linux'; - } - return null; + if (platformString.includes("mac") || platformString.includes("darwin")) { + return "mac"; + } + if (platformString.includes("win")) { + return "windows"; + } + if (platformString.includes("linux")) { + return "linux"; + } + return null; } function markRecommendedDownload(section) { - const cards = Array.from(section.querySelectorAll('.download-card')); - if (!cards.length) { - return; + const cards = Array.from(section.querySelectorAll(".download-card")); + if (!cards.length) { + return; + } + + const platform = detectPlatform(); + const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0]; + + cards.forEach((card) => { + const isPreferred = card === preferredCard; + card.classList.toggle("download-card--active", isPreferred); + const badge = card.querySelector(".recommended-badge"); + if (badge) { + badge.classList.toggle("hidden", !isPreferred); } - - const platform = detectPlatform(); - const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0]; - - cards.forEach((card) => { - const isPreferred = card === preferredCard; - card.classList.toggle('download-card--active', isPreferred); - const badge = card.querySelector('.recommended-badge'); - if (badge) { - badge.classList.toggle('hidden', !isPreferred); - } - }); + }); } -document.addEventListener('DOMContentLoaded', () => { - const downloadSections = document.querySelectorAll('.download-section, .download-card-block'); - downloadSections.forEach(markRecommendedDownload); +document.addEventListener("DOMContentLoaded", () => { + const downloadSections = document.querySelectorAll(".download-section, .download-card-block"); + downloadSections.forEach(markRecommendedDownload); }); diff --git a/src/main/resources/static/js/landing.js b/src/main/resources/static/js/landing.js index 13138e7..3c41e29 100644 --- a/src/main/resources/static/js/landing.js +++ b/src/main/resources/static/js/landing.js @@ -1,56 +1,54 @@ document.addEventListener("DOMContentLoaded", () => { - const searchForm = document.getElementById("channel-search-form"); - const searchInput = document.getElementById("channel-search"); - const suggestions = document.getElementById("channel-suggestions"); + const searchForm = document.getElementById("channel-search-form"); + const searchInput = document.getElementById("channel-search"); + const suggestions = document.getElementById("channel-suggestions"); - if (!searchForm || !searchInput || !suggestions) { - console.error("Required elements not found in the DOM"); - return; - } + if (!searchForm || !searchInput || !suggestions) { + console.error("Required elements not found in the DOM"); + return; + } - let channels = []; + let channels = []; - function updateSuggestions(term) { - const normalizedTerm = term.trim().toLowerCase(); - const filtered = channels - .filter((name) => !normalizedTerm || name.includes(normalizedTerm)) - .slice(0, 20); + function updateSuggestions(term) { + const normalizedTerm = term.trim().toLowerCase(); + const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20); - suggestions.innerHTML = ""; - filtered.forEach((name) => { - const option = document.createElement("option"); - option.value = name; - suggestions.appendChild(option); - }); - } - - async function loadChannels() { - try { - const response = await fetch("/api/channels"); - if (!response.ok) { - throw new Error(`Failed to load channels: ${response.status}`); - } - channels = await response.json(); - updateSuggestions(searchInput.value || ""); - } catch (error) { - console.error("Could not load channel directory", error); - } - } - - searchInput.focus({ preventScroll: true }); - searchInput.select(); - - searchInput.addEventListener("input", (event) => updateSuggestions(event.target.value || "")); - - searchForm.addEventListener("submit", (event) => { - event.preventDefault(); - const broadcaster = (searchInput.value || "").trim().toLowerCase(); - if (!broadcaster) { - searchInput.focus(); - return; - } - window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`; + suggestions.innerHTML = ""; + filtered.forEach((name) => { + const option = document.createElement("option"); + option.value = name; + suggestions.appendChild(option); }); + } - loadChannels(); + async function loadChannels() { + try { + const response = await fetch("/api/channels"); + if (!response.ok) { + throw new Error(`Failed to load channels: ${response.status}`); + } + channels = await response.json(); + updateSuggestions(searchInput.value || ""); + } catch (error) { + console.error("Could not load channel directory", error); + } + } + + searchInput.focus({ preventScroll: true }); + searchInput.select(); + + searchInput.addEventListener("input", (event) => updateSuggestions(event.target.value || "")); + + searchForm.addEventListener("submit", (event) => { + event.preventDefault(); + const broadcaster = (searchInput.value || "").trim().toLowerCase(); + if (!broadcaster) { + searchInput.focus(); + return; + } + window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`; + }); + + loadChannels(); }); diff --git a/src/main/resources/static/js/settings.js b/src/main/resources/static/js/settings.js index 662f38b..9b17cee 100644 --- a/src/main/resources/static/js/settings.js +++ b/src/main/resources/static/js/settings.js @@ -19,126 +19,130 @@ const currentSettings = JSON.parse(serverRenderedSettings); let userSettings = { ...currentSettings }; function jsonEquals(a, b) { - if (a === b) return true; + if (a === b) return true; - if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { - return false; - } + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { + return false; + } - const keysA = Object.keys(a); - const keysB = Object.keys(b); + const keysA = Object.keys(a); + const keysB = Object.keys(b); - if (keysA.length !== keysB.length) return false; + if (keysA.length !== keysB.length) return false; - for (const key of keysA) { - if (!keysB.includes(key)) return false; - if (!jsonEquals(a[key], b[key])) return false; - } + for (const key of keysA) { + if (!keysB.includes(key)) return false; + if (!jsonEquals(a[key], b[key])) return false; + } - return true; + return true; } function setFormSettings(s) { - canvasFpsElement.value = s.canvasFramesPerSecond; - canvasSizeElement.value = s.maxCanvasSideLengthPixels; + canvasFpsElement.value = s.canvasFramesPerSecond; + canvasSizeElement.value = s.maxCanvasSideLengthPixels; - minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction; - maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction; - minPitchElement.value = s.minAssetAudioPitchFraction; - maxPitchElement.value = s.maxAssetAudioPitchFraction; - minVolumeElement.value = s.minAssetVolumeFraction; - maxVolumeElement.value = s.maxAssetVolumeFraction; + minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction; + maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction; + minPitchElement.value = s.minAssetAudioPitchFraction; + maxPitchElement.value = s.maxAssetAudioPitchFraction; + minVolumeElement.value = s.minAssetVolumeFraction; + maxVolumeElement.value = s.maxAssetVolumeFraction; } function updateStatCards(settings) { - if (!settings) return; - statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`; - statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`; - statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`; - statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`; - statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`; + if (!settings) return; + statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`; + statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`; + statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`; + statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`; + statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`; } function readInt(input) { - return input.checkValidity() ? Number(input.value) : null; + return input.checkValidity() ? Number(input.value) : null; } function readFloat(input) { - return input.checkValidity() ? Number(input.value) : null; + return input.checkValidity() ? Number(input.value) : null; } function loadUserSettingsFromDom() { - userSettings.canvasFramesPerSecond = readInt(canvasFpsElement); - userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement); - userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement); - userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement); - userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement); - userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement); - userSettings.minAssetVolumeFraction = readFloat(minVolumeElement); - userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement); + userSettings.canvasFramesPerSecond = readInt(canvasFpsElement); + userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement); + userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement); + userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement); + userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement); + userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement); + userSettings.minAssetVolumeFraction = readFloat(minVolumeElement); + userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement); } function updateSubmitButtonDisabledState() { - if (jsonEquals(currentSettings, userSettings)) { - submitButtonElement.disabled = "disabled"; - statusElement.textContent = "No changes yet."; - statusElement.classList.remove("status-success", "status-warning"); - return; - } - if (!formElement.checkValidity()) { - submitButtonElement.disabled = "disabled"; - statusElement.textContent = "Fix highlighted fields."; - statusElement.classList.add("status-warning"); - statusElement.classList.remove("status-success"); - return; - } - submitButtonElement.disabled = null; - statusElement.textContent = "Ready to save."; - statusElement.classList.remove("status-warning"); + if (jsonEquals(currentSettings, userSettings)) { + submitButtonElement.disabled = "disabled"; + statusElement.textContent = "No changes yet."; + statusElement.classList.remove("status-success", "status-warning"); + return; + } + if (!formElement.checkValidity()) { + submitButtonElement.disabled = "disabled"; + statusElement.textContent = "Fix highlighted fields."; + statusElement.classList.add("status-warning"); + statusElement.classList.remove("status-success"); + return; + } + submitButtonElement.disabled = null; + statusElement.textContent = "Ready to save."; + statusElement.classList.remove("status-warning"); } function submitSettingsForm() { - if (submitButtonElement.getAttribute("disabled") != null) { - console.warn("Attempted to submit invalid form"); - showToast("Settings not valid", "warning"); - return; - } - statusElement.textContent = "Saving…"; - statusElement.classList.remove("status-success", "status-warning"); - fetch("/api/settings/set", { method: "PUT", headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(userSettings) }).then((r) => { - if (!r.ok) { - throw new Error('Failed to load canvas'); - } - return r.json(); - + if (submitButtonElement.getAttribute("disabled") != null) { + console.warn("Attempted to submit invalid form"); + showToast("Settings not valid", "warning"); + return; + } + statusElement.textContent = "Saving…"; + statusElement.classList.remove("status-success", "status-warning"); + fetch("/api/settings/set", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(userSettings), + }) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load canvas"); + } + return r.json(); }) - .then((newSettings) => { - currentSettings = { ...newSettings }; - userSettings = { ...newSettings }; - updateStatCards(newSettings); - showToast("Settings saved", "success"); - statusElement.textContent = "Saved."; - statusElement.classList.add("status-success"); - updateSubmitButtonDisabledState(); - }) - .catch((error) => { - showToast('Unable to save settings', 'error') - console.error(error); - statusElement.textContent = "Save failed. Try again."; - statusElement.classList.add("status-warning"); - }); + .then((newSettings) => { + currentSettings = { ...newSettings }; + userSettings = { ...newSettings }; + updateStatCards(newSettings); + showToast("Settings saved", "success"); + statusElement.textContent = "Saved."; + statusElement.classList.add("status-success"); + updateSubmitButtonDisabledState(); + }) + .catch((error) => { + showToast("Unable to save settings", "error"); + console.error(error); + statusElement.textContent = "Save failed. Try again."; + statusElement.classList.add("status-warning"); + }); } formElement.querySelectorAll("input").forEach((input) => { - input.addEventListener("input", () => { - loadUserSettingsFromDom(); - updateSubmitButtonDisabledState(); - }); + input.addEventListener("input", () => { + loadUserSettingsFromDom(); + updateSubmitButtonDisabledState(); + }); }); formElement.addEventListener("submit", (event) => { - event.preventDefault(); - submitSettingsForm(); + event.preventDefault(); + submitSettingsForm(); }); setFormSettings(currentSettings); diff --git a/src/main/resources/static/js/toast.js b/src/main/resources/static/js/toast.js index c9cdff7..a4146ae 100644 --- a/src/main/resources/static/js/toast.js +++ b/src/main/resources/static/js/toast.js @@ -1,51 +1,51 @@ (function () { - const CONTAINER_ID = 'toast-container'; - const DEFAULT_DURATION = 4200; + const CONTAINER_ID = "toast-container"; + const DEFAULT_DURATION = 4200; - function ensureContainer() { - let container = document.getElementById(CONTAINER_ID); - if (!container) { - container = document.createElement('div'); - container.id = CONTAINER_ID; - container.className = 'toast-container'; - container.setAttribute('aria-live', 'polite'); - container.setAttribute('aria-atomic', 'true'); - document.body.appendChild(container); - } - return container; + function ensureContainer() { + let container = document.getElementById(CONTAINER_ID); + if (!container) { + container = document.createElement("div"); + container.id = CONTAINER_ID; + container.className = "toast-container"; + container.setAttribute("aria-live", "polite"); + container.setAttribute("aria-atomic", "true"); + document.body.appendChild(container); } + return container; + } - function buildToast(message, type) { - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; + function buildToast(message, type) { + const toast = document.createElement("div"); + toast.className = `toast toast-${type}`; - const indicator = document.createElement('span'); - indicator.className = 'toast-indicator'; - indicator.setAttribute('aria-hidden', 'true'); + const indicator = document.createElement("span"); + indicator.className = "toast-indicator"; + indicator.setAttribute("aria-hidden", "true"); - const content = document.createElement('div'); - content.className = 'toast-message'; - content.textContent = message; + const content = document.createElement("div"); + content.className = "toast-message"; + content.textContent = message; - toast.appendChild(indicator); - toast.appendChild(content); - return toast; - } + toast.appendChild(indicator); + toast.appendChild(content); + return toast; + } - function removeToast(toast) { - if (!toast) return; - toast.classList.add('toast-exit'); - setTimeout(() => toast.remove(), 250); - } + function removeToast(toast) { + if (!toast) return; + toast.classList.add("toast-exit"); + setTimeout(() => toast.remove(), 250); + } - window.showToast = function showToast(message, type = 'info', options = {}) { - if (!message) return; - const normalized = ['success', 'error', 'warning', 'info'].includes(type) ? type : 'info'; - const duration = typeof options.duration === 'number' ? options.duration : DEFAULT_DURATION; - const container = ensureContainer(); - const toast = buildToast(message, normalized); - container.appendChild(toast); - setTimeout(() => removeToast(toast), Math.max(1200, duration)); - toast.addEventListener('click', () => removeToast(toast)); - }; + window.showToast = function showToast(message, type = "info", options = {}) { + if (!message) return; + const normalized = ["success", "error", "warning", "info"].includes(type) ? type : "info"; + const duration = typeof options.duration === "number" ? options.duration : DEFAULT_DURATION; + const container = ensureContainer(); + const toast = buildToast(message, normalized); + container.appendChild(toast); + setTimeout(() => removeToast(toast), Math.max(1200, duration)); + toast.addEventListener("click", () => removeToast(toast)); + }; })(); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 71dbb32..8e0f052 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -1,216 +1,306 @@ - + - - + + Imgfloat Admin - + - - -
-
+ + +
+
-
-

CHANNEL ADMIN

-

-
+
+

CHANNEL ADMIN

+

+
-
+
-
+
-
-
-

Canvas

-

Live composition

-
-
- 1920 x 1080 - 100% -
+
+
+

Canvas

+

Live composition

-
-
-
- -
-
-

Edges of the canvas are outlined to match the aspect ratio of the stream.

-
+
+ 1920 x 1080 + 100%
+
+
+
+
+ +
+
+

Edges of the canvas are outlined to match the aspect ratio of the stream.

+
+
+
-
- - - - + + + + diff --git a/src/main/resources/templates/broadcast.html b/src/main/resources/templates/broadcast.html index 8c35216..fe21b6a 100644 --- a/src/main/resources/templates/broadcast.html +++ b/src/main/resources/templates/broadcast.html @@ -1,18 +1,18 @@ - + - - + + Imgfloat Broadcast - - - - - - - + + + + + + + diff --git a/src/main/resources/templates/channels.html b/src/main/resources/templates/channels.html index e7734f4..b1231c6 100644 --- a/src/main/resources/templates/channels.html +++ b/src/main/resources/templates/channels.html @@ -1,36 +1,44 @@ - + - - + + Browse channels - Imgfloat - - -
-
+ + +
+
-
IF
-
-
Imgfloat
-
Twitch overlay manager
-
+
IF
+
+
Imgfloat
+
Twitch overlay manager
+
-
+
-
+
-

Broadcast overlay

-

Open a channel

-

Type the channel name to jump straight to their overlay.

-
- - - - -
+

Broadcast overlay

+

Open a channel

+

Type the channel name to jump straight to their overlay.

+
+ + + + +
-
-
- - + + + + diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index e134258..aeb6d0b 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -1,151 +1,166 @@ - + - - + + Imgfloat Dashboard - - -
-
+ + +
+
-
IF
-
-
Imgfloat
-
Twitch overlay manager
-
+
IF
+
+
Imgfloat
+
Twitch overlay manager
+
- Signed in as - user + Signed in as + user
-
+
-
+

Navigation

Shortcuts

Jump into your overlay

-
+
-
+

Settings

Overlay dimensions

Match these with your OBS resolution.

- - + +
- - + +
-
+
-
+
-
-
-

Collaboration

-

Channel admins

-

Invite moderators to help manage assets.

-
-
-
- - -
-
-
-

Channel Admins

-

Users who can currently modify your overlay.

-
-
    -
    -
    -
    -

    Your Twitch moderators

    -

    Add moderators who already help run your channel.

    -
    -
      -
      -
      -
      - -
      -
      +
      -

      Your access

      -

      Channels you administer

      -

      Jump into a teammate's overlay console.

      +

      Collaboration

      +

      Channel admins

      +

      Invite moderators to help manage assets.

      +
      +
      + + +
      +
      +
      +

      Channel Admins

      +

      Users who can currently modify your overlay.

      +
      +
        +
        +
        +
        +

        Your Twitch moderators

        +

        Add moderators who already help run your channel.

        +
        +
          +
          +
          +
          + +
          +
          +
          +

          Your access

          +

          Channels you administer

          +

          Jump into a teammate's overlay console.

          +

          No admin invitations yet.

            -
          • -
            -

            channel

            -

            Channel admin access

            -
            - Open -
          • -
          -
          - -
          -
          +
        • -

          Desktop app

          -

          Download Imgfloat

          -

          Version 0.0.1 · build unknown

          +

          channel

          +

          Channel admin access

          + Open +
        • + +
          + +
          +
          +
          +

          Desktop app

          +

          Download Imgfloat

          +

          + Version 0.0.1 · build + unknown +

          +
          -
          -
          -

          macOS

          - -
          -

          Apple Silicon build (ARM64)

          - Download .dmg +
          +
          +

          macOS

          +
          -
          -
          -

          Windows

          - -
          -

          Installer for Windows 10 and 11

          - Download .exe +

          Apple Silicon build (ARM64)

          + Download .dmg +
          +
          +
          +

          Windows

          +
          -
          -
          -

          Linux

          - -
          -

          AppImage for most distributions

          - Download AppImage +

          Installer for Windows 10 and 11

          + Download .exe +
          +
          +
          +

          Linux

          +
          +

          AppImage for most distributions

          + Download AppImage +
          -
          -
          - - - - - + + + + + + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 592fb40..d14a661 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -1,79 +1,95 @@ - + - - + + Imgfloat - Twitch overlay - - -
          -
          + + +
          +
          -
          IF
          -
          -
          Imgfloat
          -
          Twitch overlay manager
          -
          +
          IF
          +
          +
          Imgfloat
          +
          Twitch overlay manager
          +
          -
          +
          -
          +
          -

          Overlay toolkit

          -

          Keep your Twitch overlays tidy.

          -

          Upload artwork, drop it into a shared dashboard, and stay in sync with your mods without clutter.

          - +

          Overlay toolkit

          +

          Keep your Twitch overlays tidy.

          +

          + Upload artwork, drop it into a shared dashboard, and stay in sync with your mods without clutter. +

          +
          -
          +
          -
          +
          -

          Desktop app

          -

          Download Imgfloat

          -

          Version 0.0.1 for Windows, macOS, and Linux.

          +

          Desktop app

          +

          Download Imgfloat

          +

          + Version 0.0.1 for Windows, macOS, and Linux. +

          -
          -
          -

          macOS

          - -
          -

          Apple Silicon build (ARM64)

          - Download .dmg +
          +
          +

          macOS

          +
          -
          -
          -

          Windows

          - -
          -

          Installer for Windows 10 and 11

          - Download .exe +

          Apple Silicon build (ARM64)

          + Download .dmg +
          +
          +
          +

          Windows

          +
          -
          -
          -

          Linux

          - -
          -

          AppImage for most distributions

          - Download AppImage +

          Installer for Windows 10 and 11

          + Download .exe +
          +
          +
          +

          Linux

          +
          +

          AppImage for most distributions

          + Download AppImage +
          -
          +
          -
          +
          - License - MIT + License + MIT
          - Build - unknown + Build + unknown
          -
          -
          - - + + + + diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 546ff1f..ec06995 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -1,234 +1,254 @@ - + - - - Imgfloat Admin - - - - - - -
          -
          -
          -
          IF
          -
          -
          Imgfloat
          -
          Twitch overlay manager
          -
          -
          -
          - -
          -
          -
          -

          System administrator settings

          -

          Application defaults

          -

          - Configure overlay performance and audio guardrails for every channel using Imgfloat. - These settings are applied globally. -

          -
          - Performance tuned - Server-wide - Admin only -
          -
          -
          -
          -

          Canvas FPS

          -

          --

          -

          Longest side --

          -
          -
          -

          Playback speed

          -

          --

          -

          Applies to all animations

          -
          -
          -

          Audio pitch

          -

          --

          -

          Fraction of original clip

          -
          -
          -

          Volume limits

          -

          --

          -

          Keeps alerts comfortable

          -
          -
          -
          - -
          -
          -
          -
          -

          Overlay defaults

          -

          Performance & audio budget

          -

          Tune the canvas and audio guardrails to keep overlays smooth and balanced.

          -
          -
          - -
          -
          -
          -

          Canvas

          -

          Rendering budget

          -

          Match FPS and max dimensions to your streaming canvas for consistent overlays.

          -
          -
          - - - -
          -

          Use the longest edge of your OBS browser source to prevent stretching.

          -
          - -
          -
          -

          Playback

          -

          Animation speed limits

          -

          Bound default speeds between 0 and 1 so clips run predictably.

          -
          -
          - - - -
          -

          Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate.

          -
          - -
          -
          -

          Audio

          -

          Pitch & volume guardrails

          -

          Prevent harsh audio by bounding pitch and volume as fractions of the source.

          -
          -
          - - - -
          -
          - - - -
          -

          Volume and pitch values are percentages of the original clip between 0 and 1.

          -
          - - -
          -
          - - -
          -
          + + + Imgfloat Admin + + + + + + +
          +
          +
          +
          IF
          +
          +
          Imgfloat
          +
          Twitch overlay manager
          +
          - - - - +
          + +
          +
          +
          +

          System administrator settings

          +

          Application defaults

          +

          + Configure overlay performance and audio guardrails for every channel using Imgfloat. These settings are + applied globally. +

          +
          + Performance tuned + Server-wide + Admin only +
          +
          +
          +
          +

          Canvas FPS

          +

          --

          +

          Longest side --

          +
          +
          +

          Playback speed

          +

          --

          +

          Applies to all animations

          +
          +
          +

          Audio pitch

          +

          --

          +

          Fraction of original clip

          +
          +
          +

          Volume limits

          +

          --

          +

          Keeps alerts comfortable

          +
          +
          +
          + +
          +
          +
          +
          +

          Overlay defaults

          +

          Performance & audio budget

          +

          Tune the canvas and audio guardrails to keep overlays smooth and balanced.

          +
          +
          + +
          +
          +
          +

          Canvas

          +

          Rendering budget

          +

          + Match FPS and max dimensions to your streaming canvas for consistent overlays. +

          +
          +
          + + + +
          +

          Use the longest edge of your OBS browser source to prevent stretching.

          +
          + +
          +
          +

          Playback

          +

          Animation speed limits

          +

          Bound default speeds between 0 and 1 so clips run predictably.

          +
          +
          + + + +
          +

          + Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate. +

          +
          + +
          +
          +

          Audio

          +

          Pitch & volume guardrails

          +

          Prevent harsh audio by bounding pitch and volume as fractions of the source.

          +
          +
          + + + +
          +
          + + + +
          +

          Volume and pitch values are percentages of the original clip between 0 and 1.

          +
          + + +
          +
          + + +
          +
          +
          + + + +