From f3ac9ff64a594b58d310450cb5ac0c4c54c4ff66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Z=20=E2=9D=A4=27s=20Life?= Date: Wed, 5 Nov 2025 14:44:54 -0500 Subject: [PATCH] Add: snowfall (#474) * [Feat] Add Plugin: Snowfall * Add a plugin called Snowfall, based on the BetterDiscord plugin by the same name. Lets snow fall from the top of your discord client. * [QOL] snowfall: Credit BetterDiscord Author * Here Comes Santa Claus * [Fix] snowfall: Fix Several Bugs * Switch from HTML entity on the content option to the Unicode character. * Fixed type switching bug that causes memory leaks. * Fixed image loading bug. * Fixed content array handing for image sources. * [Feat/QOL] snowfall: Configurability Overhaul * Fixed switching between types. * Created settings option for user to select type. * Created description to warn of possible performance issues. * Created settings options for users to set speed, amount, and size. * Created custom listener to check for settings changes. * Added hardcoded SVGs for Snowflakes when using the image option (including a custom-made discord one as an easter-egg). * Fix condition for deviation check in random function --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- src/equicordplugins/snowfall/index.tsx | 491 ++++++++++++++++++++++++ src/plugins/devCompanion.dev/initWs.tsx | 1 + src/utils/constants.ts | 4 + 3 files changed, 496 insertions(+) create mode 100644 src/equicordplugins/snowfall/index.tsx diff --git a/src/equicordplugins/snowfall/index.tsx b/src/equicordplugins/snowfall/index.tsx new file mode 100644 index 000000000..f5390502c --- /dev/null +++ b/src/equicordplugins/snowfall/index.tsx @@ -0,0 +1,491 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { Heading } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { createRoot, React } from "@webpack/common"; +import type { Root } from "react-dom/client"; + +const SnowfallCSS = ` +#snowfield { + pointer-events: none; + user-select: none; + z-index: 100000; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; +} +.snowflake { + position: absolute; + color: #fff; + line-height: 1; + -webkit-font-smoothing: antialiased; +} +.snowflake-solid { + border-radius: 50%; + background: #ffffff; +} +.snowflake-image { + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.5)); +} +`; + +// SVG snowflake images as data URIs +const SNOWFLAKE_SVGS = [ + // 6-pointed snowflake + "data:image/svg+xml," + encodeURIComponent(` + + + + + `), + // 8-pointed star snowflake + "data:image/svg+xml," + encodeURIComponent(` + + + + + + + + + + `), + // Discord snowflake + "data:image/svg+xml," + encodeURIComponent(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `), +]; + +const settings = definePluginSettings({ + typeOfSnow: { + description: "Change the type of snow displayed (Affects performance).", + type: OptionType.SELECT, + options: [ + { label: "Solid (Highest Performance)", value: "solid" }, + { label: "Text (Medium Performance)", value: "text", default: true }, + { label: "Image (Lowest Performance)", value: "image" } + ], + }, + maxSize: { + description: "Maximum snowflake size", + type: OptionType.SLIDER, + default: 30, + markers: [10, 20, 30, 40, 50] + }, + speed: { + description: "Snowfall speed (higher = faster fall)", + type: OptionType.SLIDER, + default: 50, + markers: [50, 100, 200, 300, 400, 500] + }, + flakesPerSecond: { + description: "Snowflakes per second (higher = denser snowfall)", + type: OptionType.SLIDER, + default: 5, + markers: [1, 5, 10, 20, 40, 60], + min: 1, + max: 60, + } +}); + +class CopleSnow { + private static winWidth = window.innerWidth; + private static winHeight = window.innerHeight; + + static readonly defaultOptions = { + minSize: 10, + maxSize: 30, + type: "text" as "text" | "solid" | "image", + content: "❄" as string | string[], + fadeOut: true, + autoplay: true, + interval: 200 + }; + + private static cssPrefix(propertyName: string): string | null { + const capitalize = propertyName.charAt(0).toUpperCase() + propertyName.slice(1); + const tempDiv = document.createElement("div"); + const { style } = tempDiv; + const prefixes = ["Webkit", "Moz", "ms", "O"]; + + if (propertyName in style) return propertyName; + for (const prefix of prefixes) { + const name = prefix + capitalize; + if (name in style) return name; + } + return null; + } + + private static readonly cssPrefixedNames = { + transform: this.cssPrefix("transform"), + transition: this.cssPrefix("transition") + }; + + private static readonly transitionEndEvent = + { WebkitTransition: "webkitTransitionEnd", OTransition: "oTransitionEnd", Moztransition: "transitionend", transition: "transitionend" }[ + this.cssPrefixedNames.transition ?? "transition" + ] ?? "transitionend"; + + private static random(min: number, max: number, deviation?: number): number { + if (!deviation) { + deviation *= max; + max += deviation; + min = max - deviation; + } else { + min = min || 0; + } + return Math.floor(Math.random() * (max - min + 1) + min); + } + + private static setStyle(element: HTMLElement, rules: Record) { + for (const [name, value] of Object.entries(rules)) { + const cssName = CopleSnow.cssPrefixedNames[name as keyof typeof CopleSnow.cssPrefixedNames] || name; + (element.style as any)[cssName] = value; + } + } + + private options = { ...CopleSnow.defaultOptions }; + private queue: HTMLElement[] = []; + private $snowfield: HTMLDivElement; + private timer: number | null = null; + public playing = false; + + constructor(newOptions: Partial = {}) { + Object.assign(this.options, newOptions); + + this.$snowfield = document.createElement("div"); + this.$snowfield.id = "snowfield"; + document.body.appendChild(this.$snowfield); + + const updateSize = () => { + CopleSnow.winHeight = window.innerHeight; + CopleSnow.winWidth = window.innerWidth; + }; + window.addEventListener("resize", updateSize); + (this as any)._resizeHandler = updateSize; + + this.$snowfield.addEventListener(CopleSnow.transitionEndEvent, e => { + const snowflake = e.target as HTMLElement; + if (snowflake.classList.contains("snowflake")) { + this.$snowfield.removeChild(snowflake); + this.queue.push(snowflake); + } + }); + + const handleVisibilityChange = () => { + if (document.hidden) this.stop(); + else this.play(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + (this as any)._visibilityHandler = handleVisibilityChange; + + if (this.options.autoplay) this.play(); + } + + private createSnowflake(): HTMLElement { + const { type, content } = this.options as { type: "text" | "image" | "solid"; content: string | string[]; }; + const cntLength = Array.isArray(content) ? content.length : 1; + + let snowflake: HTMLElement; + + if (type === "image") { + snowflake = document.createElement("img"); + const src = typeof content === "string" + ? content + : content[Math.floor(Math.random() * cntLength)]; + (snowflake as HTMLImageElement).src = src; + snowflake.setAttribute("draggable", "false"); + } else { + snowflake = document.createElement("div"); + if (type === "text") { + const textContent = typeof content === "string" + ? content + : content[Math.floor(Math.random() * cntLength)]; + snowflake.textContent = textContent; + } + // if type is solid we don't need to set content + } + + snowflake.className = `snowflake snowflake-${type}`; + (snowflake as any).dataset.type = type; + + return snowflake; + } + + private animateSnowflake() { + const { winWidth, winHeight } = CopleSnow; + const size = CopleSnow.random(this.options.minSize, this.options.maxSize); + const top = -2 * size; + const left = CopleSnow.random(0, winWidth - size); + const opacity = CopleSnow.random(5, 10) / 10; + const angle = CopleSnow.random(0, winHeight * 0.8, 1); + const translateX = CopleSnow.random(-100, 100); + const translateY = winHeight + size * 2; + const baseSpeed = settings.store.speed; + const duration = (winHeight * 10) / (baseSpeed / 50); + let snowflake: HTMLElement; + if (this.queue.length > 0) { + const reused = this.queue.shift()!; + if ((reused.dataset.type as string) !== this.options.type) { + snowflake = this.createSnowflake(); + } else { + snowflake = reused; + if (this.options.type === "text") { + const { content } = this.options; + if (Array.isArray(content)) { + snowflake.textContent = content[Math.floor(Math.random() * content.length)]; + } + } else if (this.options.type === "image") { + const { content } = this.options; + if (Array.isArray(content)) { + (snowflake as HTMLImageElement).src = content[Math.floor(Math.random() * content.length)]; + } + } + } + } else { + snowflake = this.createSnowflake(); + } + + const styleRules: Record = { + top: `${top}px`, + left: `${left}px`, + opacity: opacity, + transform: "none", + transition: `${duration}ms linear` + }; + + switch (this.options.type) { + case "solid": + styleRules.width = styleRules.height = `${size}px`; + break; + case "text": + styleRules.fontSize = `${size}px`; + break; + case "image": + styleRules.width = `${size}px`; + styleRules.height = "auto"; + break; + } + + CopleSnow.setStyle(snowflake, styleRules); + this.$snowfield.appendChild(snowflake); + + setTimeout(() => { + CopleSnow.setStyle(snowflake, { + transform: `translate(${translateX}px, ${translateY}px) rotate(${angle}deg)`, + opacity: this.options.fadeOut ? 0 : opacity + }); + }, 100); + } + + play() { + if (this.playing) return; + this.timer = window.setInterval(() => this.animateSnowflake(), this.options.interval); + this.playing = true; + } + + stop() { + if (!this.playing) return; + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + this.playing = false; + } + + destroy() { + this.stop(); + if (this.$snowfield.parentNode) { + this.$snowfield.remove(); + } + window.removeEventListener("resize", (this as any)._resizeHandler); + document.removeEventListener("visibilitychange", (this as any)._visibilityHandler); + } + + updateOptions(newOptions: Partial) { + Object.assign(this.options, newOptions); + } +} + +const SnowfallManager: React.FC = () => { + const snowRef = React.useRef(null); + const styleRef = React.useRef(null); + + React.useEffect(() => { + // Inject CSS + const styleEl = document.createElement("style"); + styleEl.id = "snowfall-styles"; + styleEl.textContent = SnowfallCSS; + document.head.appendChild(styleEl); + styleRef.current = styleEl; + + // Get initial snow type from settings + const snowType = settings.store.typeOfSnow as "text" | "solid" | "image"; + const snowOptions: Partial = { + autoplay: false, + type: snowType, + maxSize: settings.store.maxSize, + interval: 1000 / settings.store.flakesPerSecond + }; + + snowOptions.interval = settings.store.speed; + + // Set content based on type + if (snowType === "image") { + snowOptions.content = SNOWFLAKE_SVGS; + } + + // Create snow instance with settings + const snow = new CopleSnow(snowOptions); + snowRef.current = snow; + + const blurHandler = () => snow.stop(); + const focusHandler = () => { + if (document.hasFocus()) snow.play(); + }; + + window.addEventListener("blur", blurHandler); + window.addEventListener("focus", focusHandler); + + if (document.hasFocus()) { + snow.play(); + } + + // Custom listener for settings changes (we don't have a built-in one) + let lastSettings = { + type: settings.store.typeOfSnow as "text" | "solid" | "image", + maxSize: settings.store.maxSize, + speed: settings.store.speed, + flakesPerSecond: settings.store.flakesPerSecond + }; + + const settingsInterval = setInterval(() => { + const newSettings = { + type: settings.store.typeOfSnow as "text" | "solid" | "image", + maxSize: settings.store.maxSize, + speed: settings.store.speed, + flakesPerSecond: settings.store.flakesPerSecond + }; + + if (Object.keys(newSettings).some(k => newSettings[k as keyof typeof newSettings] !== lastSettings[k as keyof typeof lastSettings])) { + lastSettings = newSettings; + + const updateOptions: Partial = { + type: newSettings.type, + maxSize: newSettings.maxSize, + interval: 1000 / newSettings.flakesPerSecond + }; + + if (newSettings.type === "image") updateOptions.content = SNOWFLAKE_SVGS; + else if (newSettings.type === "text") updateOptions.content = "❄"; + + snow.updateOptions(updateOptions); + } + }, 5000); + + return () => { + snow.destroy(); + snowRef.current = null; + + window.removeEventListener("blur", blurHandler); + window.removeEventListener("focus", focusHandler); + + clearInterval(settingsInterval); + + if (styleEl.parentNode) { + styleEl.remove(); + } + styleRef.current = null; + + const snowfield = document.getElementById("snowfield"); + if (snowfield) snowfield.remove(); + }; + }, []); + + return null; +}; + +let snowRoot: Root | null = null; +let container: HTMLDivElement | null = null; + +export default definePlugin({ + name: "Snowfall", + description: "Let it snow on Discord! Ported from the BetterDiscord plugin by square.", + authors: [EquicordDevs.ZcraftElite, EquicordDevs.square], + + settingsAboutComponent: () => ( + <> + Information + + This plugin adds a christmas-y snowfall effect on top of Discord's interface. + You can change the type of snow in the settings below. +

+ NOTE: While on most computers this plugin will not impact performance any more than your average Equicord extension, + it may cause some lag on lower end systems. +
+ + ), + + + settings, + + start() { + container = document.createElement("div"); + container.id = "snowfall-plugin-container"; + document.body.appendChild(container); + + snowRoot = createRoot(container); + snowRoot.render(React.createElement(SnowfallManager)); + }, + + stop() { + if (snowRoot) { + snowRoot.unmount(); + snowRoot = null; + } + if (container) { + if (container.parentNode) container.remove(); + container = null; + } + } +}); diff --git a/src/plugins/devCompanion.dev/initWs.tsx b/src/plugins/devCompanion.dev/initWs.tsx index da1c9a9f0..4221a9fb7 100644 --- a/src/plugins/devCompanion.dev/initWs.tsx +++ b/src/plugins/devCompanion.dev/initWs.tsx @@ -143,6 +143,7 @@ export function initWs(isManual = false) { case "disable": { const m = d.data; const settings = Settings.plugins[m.pluginName]; + if (!settings) throw new Error("Plugin not found: " + m.pluginName); if (m.enabled !== settings.enabled) toggleEnabled(m.pluginName, reply); break; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index d18322d3c..a996c6052 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1186,6 +1186,10 @@ export const EquicordDevs = Object.freeze({ name: "Prism", id: 390884143749136386n }, + square: { + name: "square", + id: 219363409097916416n + } } satisfies Record); // iife so #__PURE__ works correctly