From a162b30bcd8191718da60f4f5d05ca5c4972935f Mon Sep 17 00:00:00 2001 From: CodeDevMLH <145071728+CodeDevMLH@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:52:36 +0100 Subject: [PATCH] Enhance underwater animation: add new creature types, improve movement animations, and implement light rays effect --- Jellyfin.Plugin.Seasonals/Web/underwater.css | 56 +++- Jellyfin.Plugin.Seasonals/Web/underwater.js | 295 ++++++++++++++----- 2 files changed, 277 insertions(+), 74 deletions(-) diff --git a/Jellyfin.Plugin.Seasonals/Web/underwater.css b/Jellyfin.Plugin.Seasonals/Web/underwater.css index 709d7ef..66d54b5 100644 --- a/Jellyfin.Plugin.Seasonals/Web/underwater.css +++ b/Jellyfin.Plugin.Seasonals/Web/underwater.css @@ -62,16 +62,29 @@ z-index: 40; } -@keyframes underwater-swim-right { - 0% { transform: translateX(0) translateY(0) scaleX(-1); } - 50% { transform: translateX(65vw) translateY(-5vh) scaleX(-1); } - 100% { transform: translateX(130vw) translateY(0) scaleX(-1); } +@keyframes underwater-traverse-right { + 0% { left: -25vw; } + 100% { left: 125vw; } } -@keyframes underwater-swim-left { - 0% { transform: translateX(0) translateY(0); } - 50% { transform: translateX(-65vw) translateY(5vh); } - 100% { transform: translateX(-130vw) translateY(0); } +@keyframes underwater-traverse-left { + 0% { left: 125vw; } + 100% { left: -25vw; } +} + +@keyframes underwater-traverse-up { + 0% { top: 120vh; } + 100% { top: -20vh; } +} + +@keyframes underwater-traverse-down { + 0% { top: -20vh; } + 100% { top: 120vh; } +} + +@keyframes underwater-sway-y { + 0% { transform: translateY(-2vh); } + 100% { transform: translateY(2vh); } } @keyframes underwater-sway { @@ -86,3 +99,30 @@ 90% { opacity: 0; } 100% { transform: translateY(-110vh) translateX(10px); opacity: 0; } } + +.underwater-god-rays { + position: absolute; + top: -50vh; + left: -50vw; + width: 200vw; + height: 200vh; + background: repeating-linear-gradient( + 15deg, + rgba(255, 255, 255, 0.02) 0px, + rgba(255, 255, 255, 0.05) 100px, + transparent 100px, + transparent 300px + ); + animation: god-rays-sway 20s ease-in-out infinite alternate; + pointer-events: none; + z-index: 12; + transform-origin: top center; + mix-blend-mode: overlay; + filter: blur(5px); +} + +@keyframes god-rays-sway { + 0% { transform: rotate(-3deg) translateX(-5%); opacity: 0.4; } + 50% { opacity: 0.8; } + 100% { transform: rotate(3deg) translateX(5%); opacity: 0.4; } +} diff --git a/Jellyfin.Plugin.Seasonals/Web/underwater.js b/Jellyfin.Plugin.Seasonals/Web/underwater.js index 7cd68db..ca2d2bd 100644 --- a/Jellyfin.Plugin.Seasonals/Web/underwater.js +++ b/Jellyfin.Plugin.Seasonals/Web/underwater.js @@ -5,6 +5,78 @@ const symbolCount = config.SymbolCount || 15; 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 enableLightRays = config.EnableLightRays !== undefined ? config.EnableLightRays : true; +const seaweedCount = config.SeaweedCount !== undefined ? config.SeaweedCount : 50; + +// Entity counts configured +const fishCount = config.FishCount !== undefined ? config.FishCount : 15; +const seahorseCount = config.SeahorseCount !== undefined ? config.SeahorseCount : 3; +const jellyfishCount = config.JellyfishCount !== undefined ? config.JellyfishCount : 3; +const turtleCount = config.TurtleCount !== undefined ? config.TurtleCount : 1; +const crabCount = config.CrabCount !== undefined ? config.CrabCount : 2; +const starfishCount = config.StarfishCount !== undefined ? config.StarfishCount : 2; +const shellCount = config.ShellCount !== undefined ? config.ShellCount : 2; + +const seaweeds = [ + "../Seasonals/Resources/underwater_assets/seaweed_1.gif", + "../Seasonals/Resources/underwater_assets/seaweed_2.gif" +]; + +// Statics for bottom +const crabImages = [ + "../Seasonals/Resources/underwater_assets/crab_1.gif", + "../Seasonals/Resources/underwater_assets/crab_2.gif", + "../Seasonals/Resources/underwater_assets/crab_3.gif" +]; + +const starfishImages = [ + "../Seasonals/Resources/underwater_assets/starfish_1.gif", + "../Seasonals/Resources/underwater_assets/starfish_2.gif" +]; + +const shellImages = [ + "../Seasonals/Resources/underwater_assets/shell_1.gif" +]; + +const fishImages = [ + "../Seasonals/Resources/underwater_assets/fish_1.gif", + "../Seasonals/Resources/underwater_assets/fish_2.gif", + "../Seasonals/Resources/underwater_assets/fish_3.gif", + "../Seasonals/Resources/underwater_assets/fish_5.gif", + "../Seasonals/Resources/underwater_assets/fish_6.gif", + "../Seasonals/Resources/underwater_assets/fish_7.png", + "../Seasonals/Resources/underwater_assets/fish_8.png", + "../Seasonals/Resources/underwater_assets/fish_9.png", + "../Seasonals/Resources/underwater_assets/fish_10.png", + "../Seasonals/Resources/underwater_assets/fish_11.png", + "../Seasonals/Resources/underwater_assets/fish_12.png", + "../Seasonals/Resources/underwater_assets/fish_13.png", + "../Seasonals/Resources/underwater_assets/fish_14.png", + "../Seasonals/Resources/underwater_assets/fish_15.png" +]; + +const seahorsesImages = [ + "../Seasonals/Resources/underwater_assets/seahorse_1.gif", + "../Seasonals/Resources/underwater_assets/seahorse_2.gif" +]; + +const turtleImages = [ + "../Seasonals/Resources/underwater_assets/turtle.gif" +]; + +const jellyfishImages = [ + "../Seasonals/Resources/underwater_assets/jellyfish_1.gif", + "../Seasonals/Resources/underwater_assets/jellyfish_2.gif" +]; + +// MARK: Base sizes for all creatures (in vh) +const seahorseSize = 8; +const turtleSize = 14; +const jellyfishSize = 18; +const fishSize = 8; +const crabSize = 4; +const starfishSize = 4; +const shellSize = 7; let msgPrinted = false; @@ -46,100 +118,197 @@ function createUnderwater() { container.className = 'underwater-container'; container.setAttribute("aria-hidden", "true"); document.body.appendChild(container); + } else { + container.innerHTML = ''; // Prevent infinite duplication on theme reload! } // Deep blue overlay const bg = document.createElement('div'); bg.className = 'underwater-bg'; container.appendChild(bg); - - const standardCount = 8; - const totalSymbols = symbolCount + standardCount; - let isMobile = window.matchMedia("only screen and (max-width: 768px)").matches; - let finalCount = totalSymbols; - - if (isMobile) { - finalCount = enableRandomMobile ? totalSymbols : standardCount; + // Light Rays (God Rays) + if (enableLightRays) { + const rays = document.createElement('div'); + rays.className = 'underwater-god-rays'; + container.appendChild(rays); } const useRandomDuration = enableDifferentDuration !== false; + let isMobile = window.matchMedia("only screen and (max-width: 768px)").matches; - // Seaweed swaying at the bottom - for (let i = 0; i < 4; i++) { + + // Seaweed swaying at the bottom (evenly distributed based on count) + const activeSeaweedCount = Math.max(1, seaweedCount); + const seaweedSpacing = 95 / activeSeaweedCount; + for (let i = 0; i < seaweedCount; i++) { let seaweed = document.createElement('div'); seaweed.className = 'underwater-seaweed'; - seaweed.style.left = `${10 + (i * 25)}vw`; + seaweed.style.position = 'absolute'; + + // MARK: Distance from the bottom edge for the seaweed + seaweed.style.bottom = '-18px'; + + let offset = (Math.random() * seaweedSpacing) - (seaweedSpacing / 2); + seaweed.style.left = `max(0vw, min(95vw, calc(${(i * seaweedSpacing)}vw + ${offset}vw)))`; seaweed.style.animationDelay = `-${Math.random() * 5}s`; - // Randomly flip - if (Math.random() > 0.5) { - seaweed.style.transform = 'scaleX(-1)'; - } + // Random parallax scale for seaweed depth + const depth = Math.random(); + const scale = 0.5 + depth * 0.7; // 0.5 to 1.2 + const blur = depth < 0.3 ? `blur(2px)` : 'none'; + seaweed.style.filter = blur; + + let flip = Math.random() > 0.5 ? 'scaleX(-1)' : 'scaleX(1)'; + seaweed.style.transform = `scale(${scale}) ${flip}`; + seaweed.style.zIndex = depth < 0.5 ? '15' : '30'; - let img = document.createElement('img'); - img.src = '../Seasonals/Resources/underwater_images/seaweed.png'; - img.onerror = function() { - this.style.display = 'none'; - this.parentElement.innerHTML = '🌿'; - this.parentElement.style.fontSize = '3rem'; - this.parentElement.style.bottom = '0'; - this.parentElement.style.transformOrigin = 'bottom center'; - }; - seaweed.appendChild(img); + // Mix Emojis and GIFs + if (Math.random() > 0.4) { + let img = document.createElement('img'); + img.src = seaweeds[Math.floor(Math.random() * seaweeds.length)]; + img.onerror = function() { + this.style.display = 'none'; + }; + seaweed.appendChild(img); + } else { + seaweed.innerHTML = '🌿'; + seaweed.style.fontSize = '3rem'; + seaweed.style.bottom = '0'; + seaweed.style.transformOrigin = 'bottom center'; + } container.appendChild(seaweed); } - const activeItems = ['fish_orange', 'fish_blue', 'jellyfish', 'turtle']; + // Static Bottom Creatures logic + function spawnStatic(imageArray, maxCount, baseSize) { + // Evaluate an actual count between 1 and maxCount if random symbols are enabled + const actualCount = (useRandomSymbols && maxCount > 0) ? Math.floor(Math.random() * maxCount) + 1 : maxCount; + for (let i = 0; i < actualCount; i++) { + let creature = document.createElement('div'); + creature.className = 'underwater-static-bottom'; + creature.style.position = 'absolute'; + creature.style.bottom = '5px'; + creature.style.left = `${Math.random() * 95}vw`; + creature.style.zIndex = '20'; // In between seaweed layers - for (let i = 0; i < finalCount; i++) { - let symbol = document.createElement('div'); + let img = document.createElement('img'); + img.src = imageArray[Math.floor(Math.random() * imageArray.length)]; + img.style.height = `${baseSize}vh`; + + // Random scale variance and flip + const scale = 0.7 + Math.random() * 0.5; // 0.7 to 1.2 x baseSize + const flip = Math.random() > 0.5 ? 'scaleX(-1)' : 'scaleX(1)'; + img.style.transform = `scale(${scale}) ${flip}`; + + img.onerror = function() { + this.style.display = 'none'; + }; + creature.appendChild(img); + container.appendChild(creature); + } + } + + spawnStatic(crabImages, crabCount, crabSize); + spawnStatic(starfishImages, starfishCount, starfishSize); + spawnStatic(shellImages, shellCount, shellSize); + + // Swimmers logic + function spawnSwimmerLoop(imageArray, maxCount, baseSize, typeName) { + if (maxCount <= 0) return; + let spawnLimit = isMobile ? (enableRandomMobile ? maxCount : Math.floor(maxCount / 2)) : maxCount; - const randomItem = activeItems[Math.floor(Math.random() * activeItems.length)]; - symbol.className = `underwater-symbol underwater-${randomItem}`; + // Randomize the actual amount spawned up to the limit + const actualCount = (useRandomSymbols && spawnLimit > 0) ? Math.floor(Math.random() * spawnLimit) + 1 : spawnLimit; + + for (let i = 0; i < actualCount; i++) { + // Spawn immediately but use negative delay to distribute them across the screen! + spawnSingleSwimmer(imageArray, baseSize, typeName); + } + } + function spawnSingleSwimmer(imageArray, baseSize, typeName) { + if (!document.querySelector('.underwater-container')) return; + + let symbol = document.createElement('div'); + symbol.className = `underwater-symbol`; + + const randomImage = imageArray[Math.floor(Math.random() * imageArray.length)]; let img = document.createElement('img'); - img.src = `../Seasonals/Resources/underwater_images/${randomItem}.png`; + img.src = randomImage; + img.style.height = `${baseSize}vh`; + img.style.width = 'auto'; + img.style.maxWidth = 'none'; + img.onerror = function() { this.style.display = 'none'; - this.parentElement.innerHTML = getUnderwaterEmojiFallback(randomItem); }; - symbol.appendChild(img); - const topPos = 10 + Math.random() * 80; // 10 to 90vh - const delaySeconds = Math.random() * 10; + const depth = Math.random(); + const distanceScale = 0.4 + (depth * 0.8); + const blurAmount = depth < 0.4 ? (1 - depth) * 3 : 0; + const opacity = 0.4 + (depth * 0.5); - let durationSeconds = 15; - if (useRandomDuration) { - durationSeconds = Math.random() * 10 + 15; // 15 to 25 seconds slow swimming - } + symbol.style.opacity = `${opacity}`; + symbol.style.filter = `blur(${blurAmount}px)`; + symbol.style.zIndex = Math.floor(depth * 30) + 10; + + symbol.style.animationIterationCount = 'infinite'; - // Randomly pick direction: left-to-right OR right-to-left - const goRight = Math.random() > 0.5; - if (goRight) { - symbol.style.animationName = 'underwater-swim-right'; - symbol.style.left = '-10vw'; + let durationSeconds = (1 - depth) * 20 + 15 + Math.random() * 5; + if (!useRandomDuration) durationSeconds = 20; + + // Apply a negative delay on spawn so they start mid-screen scattered + const startDelay = -(Math.random() * durationSeconds); + + // Animate based on type + if (typeName === 'jellyfish') { + const goUp = Math.random() > 0.5; + symbol.style.animationName = goUp ? 'underwater-traverse-up' : 'underwater-traverse-down'; + symbol.style.left = `${Math.random() * 90}vw`; + + const flip = Math.random() > 0.5 ? 'scaleX(-1)' : 'scaleX(1)'; + symbol.style.transform = `scale(${distanceScale}) ${flip}`; + + durationSeconds *= 0.8; + symbol.style.animationDuration = `${durationSeconds}s`; + symbol.style.animationDelay = `${startDelay}s`; + + symbol.appendChild(img); } else { - symbol.style.animationName = 'underwater-swim-left'; - symbol.style.right = '-10vw'; - symbol.style.transform = 'scaleX(-1)'; // flip fish to face left + const goRight = Math.random() > 0.5; + const directionScale = goRight ? 'scaleX(-1)' : 'scaleX(1)'; + + symbol.style.animationName = goRight ? 'underwater-traverse-right' : 'underwater-traverse-left'; + symbol.style.animationDelay = `${startDelay}s`; + + const rotationDiv = document.createElement('div'); + let swayDur = Math.random() * 2 + 2; + if (typeName === 'seahorse') swayDur *= 1.5; + else if (typeName === 'turtle') swayDur *= 2; + + rotationDiv.style.animation = `underwater-sway-y ${swayDur}s ease-in-out infinite alternate`; + // Random internal sway to prevent synchronized wiggling + rotationDiv.style.animationDelay = `-${Math.random() * 5}s`; + + // Apply flip scale directly to the image inside rotationDiv + img.style.transform = `scale(${distanceScale}) ${directionScale}`; + rotationDiv.appendChild(img); + + symbol.appendChild(rotationDiv); + + symbol.style.top = `${Math.random() * 80 + 5}vh`; + symbol.style.animationDuration = `${durationSeconds}s`; } - symbol.style.top = `${topPos}vh`; - symbol.style.animationDuration = `${durationSeconds}s`; - symbol.style.animationDelay = `${delaySeconds}s`; - - // Small vertical sway - const swimSway = document.createElement('div'); - swimSway.style.animation = `underwater-sway ${Math.random() * 2 + 3}s ease-in-out infinite`; - swimSway.appendChild(symbol.cloneNode(true)); - symbol.innerHTML = ''; - symbol.appendChild(swimSway); - container.appendChild(symbol); } - // Bubbles + // Start swimmer loops + spawnSwimmerLoop(fishImages, fishCount, fishSize, 'fish'); + spawnSwimmerLoop(seahorsesImages, seahorseCount, seahorseSize, 'seahorse'); + spawnSwimmerLoop(jellyfishImages, jellyfishCount, jellyfishSize, 'jellyfish'); + spawnSwimmerLoop(turtleImages, turtleCount, turtleSize, 'turtle'); const bubbleCount = isMobile ? 15 : 30; for (let i = 0; i < bubbleCount; i++) { @@ -163,13 +332,7 @@ function createUnderwater() { } } -function getUnderwaterEmojiFallback(type) { - if (type === 'fish_orange') return '🐠'; - if (type === 'fish_blue') return '🐟'; - if (type === 'jellyfish') return '🪼'; - if (type === 'turtle') return '🐢'; - return '🫧'; -} + function initializeUnderwater() { if (!underwater) return;