diff --git a/Jellyfin.Plugin.Seasonals/Web/carnival.css b/Jellyfin.Plugin.Seasonals/Web/carnival.css new file mode 100644 index 0000000..bb96bd6 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/carnival.css @@ -0,0 +1,101 @@ +.carnival-container { + display: block; + position: fixed; + overflow: hidden; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 10; + perspective: 600px; +} + +.carnival-wrapper { + position: fixed; + z-index: 15; + top: -20px; + will-change: top; + animation-name: carnival-fall; + animation-timing-function: linear; + animation-iteration-count: infinite; +} + +.carnival-sway-wrapper { + will-change: transform; + animation-name: carnival-sway; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + animation-direction: alternate; +} + +.carnival-confetti { + width: 8px; + height: 16px; + background-color: #f0f; + will-change: transform; + animation-name: carnival-flutter; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + animation-duration: 2s; +} + +.carnival-confetti.circle { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.carnival-confetti.square { + width: 8px; + height: 8px; +} + +.carnival-confetti.triangle { + width: 0; + height: 0; + background-color: transparent !important; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 10px solid; + width: 10px; + height: 10px; + background-color: inherit; + clip-path: polygon(50% 0%, 0% 100%, 100% 100%); +} + +@keyframes carnival-fall { + 0% { + top: -10%; + } + 100% { + top: 110%; + } +} + +@keyframes carnival-sway { + 0% { + transform: translateX(calc(var(--sway-amount, 50px) * -1)); + } + 100% { + transform: translateX(var(--sway-amount, 50px)); + } +} + +@keyframes carnival-flutter { + 0% { + transform: rotate3d(1, 1, 1, 0deg); + } + 25% { + transform: rotate3d(1, 0.5, 0, 90deg); + } + 50% { + transform: rotate3d(0.5, 1, 0.5, 180deg); + } + 75% { + transform: rotate3d(0, 0.5, 1, 270deg); + } + 100% { + transform: rotate3d(1, 1, 1, 360deg); + } +} diff --git a/Jellyfin.Plugin.Seasonals/Web/carnival.js b/Jellyfin.Plugin.Seasonals/Web/carnival.js new file mode 100644 index 0000000..fba1f12 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/carnival.js @@ -0,0 +1,174 @@ +const config = window.SeasonalsPluginConfig?.Carnival || {}; + +const carnival = config.EnableCarnival !== undefined ? config.EnableCarnival : true; +const randomCarnival = config.EnableRandomCarnival !== undefined ? config.EnableRandomCarnival : true; +const randomCarnivalMobile = config.EnableRandomCarnivalMobile !== undefined ? config.EnableRandomCarnivalMobile : false; +const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; +const enableSway = config.EnableCarnivalSway !== undefined ? config.EnableCarnivalSway : true; +const carnivalCount = config.ObjectCount || 80; + +let msgPrinted = false; + +// function to check and control the carnival animation +function toggleCarnival() { + const carnivalContainer = document.querySelector('.carnival-container'); + if (!carnivalContainer) return; + + const videoPlayer = document.querySelector('.videoPlayerContainer'); + const trailerPlayer = document.querySelector('.youtubePlayerContainer'); + const isDashboard = document.body.classList.contains('dashboardDocument'); + const hasUserMenu = document.querySelector('#app-user-menu'); + + // hide carnival if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + carnivalContainer.style.display = 'none'; // hide carnival + if (!msgPrinted) { + console.log('Carnival hidden'); + msgPrinted = true; + } + } else { + carnivalContainer.style.display = 'block'; // show carnival + if (msgPrinted) { + console.log('Carnival visible'); + msgPrinted = false; + } + } +} + +// observe changes in the DOM +const observer = new MutationObserver(toggleCarnival); + +// start observation +observer.observe(document.body, { + childList: true, // observe adding/removing of child elements + subtree: true, // observe all levels of the DOM tree + attributes: true // observe changes to attributes (e.g. class changes) +}); + +const confettiColors = [ + '#fce18a', '#ff726d', '#b48def', '#f4306d', + '#36c5f0', '#2ccc5d', '#e9b31d', '#9b59b6', + '#3498db', '#e74c3c', '#1abc9c', '#f1c40f' +]; + +function createConfettiPiece(container, isInitial = false) { + const wrapper = document.createElement('div'); + wrapper.classList.add('carnival-wrapper'); + + let swayWrapper = wrapper; + + if (enableSway) { + swayWrapper = document.createElement('div'); + swayWrapper.classList.add('carnival-sway-wrapper'); + wrapper.appendChild(swayWrapper); + } + + const confetti = document.createElement('div'); + confetti.classList.add('carnival-confetti'); + + // Random color + const color = confettiColors[Math.floor(Math.random() * confettiColors.length)]; + confetti.style.backgroundColor = color; + + // Random shape + const shape = Math.random(); + if (shape > 0.8) { + confetti.classList.add('circle'); + } else if (shape > 0.6) { + confetti.classList.add('square'); + } else if (shape > 0.4) { + confetti.classList.add('triangle'); + } else { + confetti.classList.add('rect'); + } + + // Random position + wrapper.style.left = `${Math.random() * 100}%`; + + // Random dimensions + if (!confetti.classList.contains('circle') && !confetti.classList.contains('square') && !confetti.classList.contains('triangle')) { + const width = Math.random() * 5 + 4; + const height = Math.random() * 6 + 8; + confetti.style.width = `${width}px`; + confetti.style.height = `${height}px`; + } + + // Animation settings + const duration = Math.random() * 5 + 5; + + let delay = 0; + if (isInitial) { + delay = -Math.random() * duration; + } else { + delay = Math.random() * 10; + } + + wrapper.style.animationDelay = `${delay}s`; + wrapper.style.animationDuration = `${duration}s`; + + if (enableSway) { + // Random sway duration + const swayDuration = Math.random() * 2 + 3; // 3-5s per cycle + swayWrapper.style.animationDuration = `${swayDuration}s`; + swayWrapper.style.animationDelay = `-${Math.random() * 5}s`; + + // Random sway amplitude (using CSS variable for dynamic keyframe) + // Sway between 30px and 100px + const swayAmount = Math.random() * 70 + 30; + const direction = Math.random() > 0.5 ? 1 : -1; + swayWrapper.style.setProperty('--sway-amount', `${swayAmount * direction}px`); + } + + // Flutter speed variation + confetti.style.animationDuration = `${Math.random() * 2 + 1}s`; + + if (enableSway) { + swayWrapper.appendChild(confetti); + wrapper.appendChild(swayWrapper); + } else { + wrapper.appendChild(confetti); + } + + container.appendChild(wrapper); +} + +function addRandomCarnivalObjects(count) { + const carnivalContainer = document.querySelector('.carnival-container'); + if (!carnivalContainer) return; + + console.log('Adding random carnival confetti'); + + for (let i = 0; i < count; i++) { + createConfettiPiece(carnivalContainer, true); + } +} + +// initialize standard carnival objects +function initCarnivalObjects() { + let container = document.querySelector('.carnival-container'); + if (!container) { + container = document.createElement("div"); + container.className = "carnival-container"; + container.setAttribute("aria-hidden", "true"); + document.body.appendChild(container); + } + + // Initial confetti + for (let i = 0; i < 30; i++) { + createConfettiPiece(container, true); + } +} + +// initialize carnival +function initializeCarnival() { + if (!carnival) return; // exit if carnival is disabled + initCarnivalObjects(); + toggleCarnival(); + + const screenWidth = window.innerWidth; + if (randomCarnival && (screenWidth > 768 || randomCarnivalMobile)) { + addRandomCarnivalObjects(carnivalCount); + } +} + +initializeCarnival();