From cee4dae76926a6c9a7360f7c17c37aa00199d6c0 Mon Sep 17 00:00:00 2001 From: CodeDevMLH <145071728+CodeDevMLH@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:53:24 +0100 Subject: [PATCH] Enhance birthday feature: add garland decoration, improve balloon pop confetti effect, and refine animation dynamics --- Jellyfin.Plugin.Seasonals/Web/birthday.css | 72 +++++++++++ Jellyfin.Plugin.Seasonals/Web/birthday.js | 132 ++++++++++++++++++++- 2 files changed, 198 insertions(+), 6 deletions(-) diff --git a/Jellyfin.Plugin.Seasonals/Web/birthday.css b/Jellyfin.Plugin.Seasonals/Web/birthday.css index b4a4882..6ef9df8 100644 --- a/Jellyfin.Plugin.Seasonals/Web/birthday.css +++ b/Jellyfin.Plugin.Seasonals/Web/birthday.css @@ -10,6 +10,23 @@ contain: strict; } +.birthday-garland { + position: absolute; + top: -1vh; + left: 0; + width: 100vw; + z-index: 55; + pointer-events: none; + /* optional: a little drop shadow for depth */ + filter: drop-shadow(0 5px 8px rgba(0,0,0,0.5)); +} + +.birthday-garland img { + width: 100%; + height: auto; + object-fit: cover; +} + .birthday-cake { position: absolute; bottom: 2vh; @@ -18,6 +35,7 @@ font-size: 8rem; z-index: 50; filter: drop-shadow(0 0 10px rgba(255,255,255,0.4)); + pointer-events: auto; } .birthday-cake img { @@ -34,6 +52,13 @@ font-size: 3rem; opacity: 0.95; z-index: 40; + pointer-events: none; /* Container itself should not block clicks */ +} + +.birthday-inner { + pointer-events: auto; /* Allow hover over the actual item */ + cursor: crosshair; + display: inline-block; } .birthday-symbol img { @@ -88,3 +113,50 @@ opacity: 0; } } + +@keyframes birthday-sway { + 0% { transform: rotate(-8deg) translateX(-5%); } + 100% { transform: rotate(8deg) translateX(5%); } +} + +@keyframes birthday-pop { + 0% { transform: scale(1); opacity: 1; filter: brightness(1); } + 30% { transform: scale(1.3); opacity: 1; filter: brightness(1.5); } + 100% { transform: scale(0); opacity: 0; filter: brightness(2); } +} + +.birthday-burst-confetti { + position: absolute; + pointer-events: none; + z-index: 1000; + will-change: transform, opacity; + animation: birthday-burst-fall 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; +} + +.birthday-burst-confetti.circle { + border-radius: 50%; +} + +.birthday-burst-confetti.triangle { + width: 0; + height: 0; + background-color: transparent !important; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 10px solid var(--shape-color, #ff0000); +} + +@keyframes birthday-burst-fall { + 0% { + transform: translate(0, 0) rotate3d(var(--rx), var(--ry), var(--rz), 0deg); + opacity: 1; + } + 30% { + transform: translate(var(--burst-x), var(--burst-y)) rotate3d(var(--rx), var(--ry), var(--rz), calc(var(--rot-dir) * 0.3)); + opacity: 1; + } + 100% { + transform: translate(var(--burst-x), calc(var(--burst-y) + 150px)) rotate3d(var(--rx), var(--ry), var(--rz), var(--rot-dir)); + opacity: 0; + } +} diff --git a/Jellyfin.Plugin.Seasonals/Web/birthday.js b/Jellyfin.Plugin.Seasonals/Web/birthday.js index 741f200..fd0ff8b 100644 --- a/Jellyfin.Plugin.Seasonals/Web/birthday.js +++ b/Jellyfin.Plugin.Seasonals/Web/birthday.js @@ -5,6 +5,13 @@ const symbolCount = config.SymbolCount || 25; const useRandomSymbols = config.EnableRandomSymbols !== undefined ? config.EnableRandomSymbols : true; const enableRandomMobile = config.EnableRandomSymbolsMobile !== undefined ? config.EnableRandomSymbolsMobile : false; const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; +const enableGarland = config.EnableGarland !== undefined ? config.EnableGarland : true; + +const birthdayImages = [ + '../Seasonals/Resources/birthday_images/balloon_1.gif', + '../Seasonals/Resources/birthday_images/balloon_2.gif', + '../Seasonals/Resources/birthday_images/balloon_3.gif' +]; let msgPrinted = false; @@ -39,6 +46,69 @@ observer.observe(document.body, { attributes: true }); +function createBalloonPopConfetti(container, x, y) { + const popConfettiColors = [ + '#fce18a', '#ff726d', '#b48def', '#f4306d', + '#36c5f0', '#2ccc5d', '#e9b31d', '#9b59b6', + '#3498db', '#e74c3c', '#1abc9c', '#f1c40f' + ]; + + // Spawn 15-20 particles + const particleCount = Math.floor(Math.random() * 5) + 15; + + for (let i = 0; i < particleCount; i++) { + const particle = document.createElement('div'); + particle.classList.add('birthday-burst-confetti'); + + // Random color + const color = popConfettiColors[Math.floor(Math.random() * popConfettiColors.length)]; + particle.style.backgroundColor = color; + + // Random shape + const shape = Math.random(); + if (shape > 0.66) { + particle.classList.add('circle'); + const size = Math.random() * 4 + 4; // 4-8px + particle.style.width = `${size}px`; + particle.style.height = `${size}px`; + } else if (shape > 0.33) { + particle.classList.add('rect'); + const width = Math.random() * 3 + 3; // 3-6px + const height = Math.random() * 4 + 6; // 6-10px + particle.style.width = `${width}px`; + particle.style.height = `${height}px`; + } else { + particle.classList.add('triangle'); + } + + particle.style.position = 'absolute'; + particle.style.left = `${x}px`; + particle.style.top = `${y}px`; + particle.style.zIndex = '1000'; + + // Random direction for explosion (circular) + const angle = Math.random() * 2 * Math.PI; + const distance = Math.random() * 60 + 20; // 20-80px burst radius + + const xOffset = Math.cos(angle) * distance; + const yOffset = Math.sin(angle) * distance; + + particle.style.setProperty('--burst-x', `${xOffset}px`); + particle.style.setProperty('--burst-y', `${yOffset}px`); + + // Random rotation during fall + particle.style.setProperty('--rot-dir', `${(Math.random() > 0.5 ? 1 : -1) * 360}deg`); + particle.style.setProperty('--rx', Math.random().toFixed(2)); + particle.style.setProperty('--ry', Math.random().toFixed(2)); + particle.style.setProperty('--rz', (Math.random() * 0.5).toFixed(2)); + + container.appendChild(particle); + + // Remove particle after animation + setTimeout(() => particle.remove(), 1500); + } +} + function createBirthday() { const container = document.querySelector('.birthday-container') || document.createElement('div'); @@ -48,11 +118,22 @@ function createBirthday() { document.body.appendChild(container); } + // Party Garland + if (enableGarland) { + const garland = document.createElement('div'); + garland.className = 'birthday-garland'; + const garlandImg = document.createElement('img'); + garlandImg.src = '../Seasonals/Resources/birthday_images/garland.png'; + garlandImg.onerror = function() { this.style.display = 'none'; }; + garland.appendChild(garlandImg); + container.appendChild(garland); + } + // Spawn Birthday Cake at the bottom const cake = document.createElement('div'); cake.className = 'birthday-cake'; let cakeImg = document.createElement('img'); - cakeImg.src = `../Seasonals/Resources/birthday_images/cake.png`; + cakeImg.src = `../Seasonals/Resources/birthday_images/cake.gif`; cakeImg.onerror = function() { this.style.display = 'none'; this.parentElement.innerHTML = '🎂'; @@ -72,8 +153,8 @@ function createBirthday() { const useRandomDuration = enableDifferentDuration !== false; - // We'll treat balloons and gifts as rising symbols - const activeItems = ['balloon_red', 'balloon_blue', 'balloon_yellow', 'gift']; + // We'll treat all balloons as rising symbols + const activeItems = ['balloon_1', 'balloon_2', 'balloon_3']; for (let i = 0; i < finalCount; i++) { let symbol = document.createElement('div'); @@ -81,20 +162,59 @@ function createBirthday() { const randomItem = activeItems[Math.floor(Math.random() * activeItems.length)]; symbol.className = `birthday-symbol birthday-${randomItem}`; + // Create inner div for sway + let innerDiv = document.createElement('div'); + innerDiv.className = 'birthday-inner'; + let img = document.createElement('img'); - img.src = `../Seasonals/Resources/birthday_images/${randomItem}.png`; + img.src = `../Seasonals/Resources/birthday_images/${randomItem}.gif`; // Use standard pop GIFs img.onerror = function() { this.style.display = 'none'; this.parentElement.innerHTML = getBirthdayEmojiFallback(randomItem); }; - symbol.appendChild(img); + innerDiv.appendChild(img); + symbol.appendChild(innerDiv); const leftPos = Math.random() * 95; const delaySeconds = Math.random() * 10; + // Far away effect + const depth = Math.random(); + const scale = 0.5 + depth * 0.7; // 0.5 to 1.2 + const zIndex = Math.floor(depth * 30) + 10; + + img.style.transform = `scale(${scale})`; + symbol.style.zIndex = zIndex; + let durationSeconds = 9; if (useRandomDuration) { - durationSeconds = Math.random() * 5 + 7; // 7 to 12 seconds + // Far strings climb slower + durationSeconds = (1 - depth) * 6 + 7 + Math.random() * 4; + } + + const isBalloon = randomItem.startsWith('balloon'); + + if (isBalloon) { + // Sway animation + const swayDur = Math.random() * 2 + 3; // 3 to 5s + const swayDir = Math.random() > 0.5 ? 'normal' : 'reverse'; + innerDiv.style.animation = `birthday-sway ${swayDur}s ease-in-out infinite alternate ${swayDir}`; + + // Interaction to pop is handled visually by the GIF, but we can still remove it on hover + innerDiv.addEventListener('mouseenter', function(e) { + if (!this.classList.contains('popped')) { + this.classList.add('popped'); + this.style.animation = 'birthday-pop 0.2s ease-out forwards'; + this.style.pointerEvents = 'none'; // avoid re-triggering + + // Create confetti burst at balloon's screen position + const rect = this.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + // Ensure the burst container is appended to the main document body or the birthday container + createBalloonPopConfetti(document.body, cx, cy); + } + }, { once: true }); } const startRot = (Math.random() * 20) - 10; // -10 to +10 spread