diff --git a/Jellyfin.Plugin.Seasonals/Web/sports.css b/Jellyfin.Plugin.Seasonals/Web/sports.css index b78e6e0..4332cca 100644 --- a/Jellyfin.Plugin.Seasonals/Web/sports.css +++ b/Jellyfin.Plugin.Seasonals/Web/sports.css @@ -13,9 +13,14 @@ .sports-symbol { position: absolute; top: -10vh; - animation: sports-fall linear infinite; + /* Default is empty, assigned in JS */ font-size: 3rem; /* Fallback emoji size */ opacity: 0.9; + z-index: 40; +} + +.sports-inner { + display: inline-block; } .sports-symbol img { @@ -35,6 +40,19 @@ border-radius: 2px; } +.sports-confetti.circle { + border-radius: 50%; +} + +.sports-confetti.triangle { + width: 0 !important; + height: 0 !important; + background-color: transparent !important; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 10px solid var(--shape-color, #FFCC00); +} + .sports-turf { position: absolute; bottom: 0; @@ -46,26 +64,60 @@ z-index: 10; } -@keyframes sports-fall { +@keyframes sports-bounce { 0% { - transform: translateY(-10vh) rotate(var(--start-rot, 0deg)); + transform: translateY(-10vh); opacity: 0; + animation-timing-function: ease-in; } - 10% { + 5% { opacity: 1; } - 85% { + 30% { + transform: translateY(85vh); + animation-timing-function: ease-out; + } /* hit ground, start bouncing up */ + 50% { + transform: translateY(40vh); + animation-timing-function: ease-in; + } /* peak of bounce, start falling */ + 70% { + transform: translateY(85vh); + animation-timing-function: ease-out; + } /* hit ground, bounce up */ + 85% { + transform: translateY(70vh); + animation-timing-function: ease-in; + } /* smaller peak */ + 95% { opacity: 1; } 100% { - transform: translateY(110vh) rotate(var(--end-rot, 360deg)); + transform: translateY(110vh); opacity: 0; } } +@keyframes sports-fall { + 0% { transform: translateY(-10vh); opacity: 0; } + 5% { opacity: 1; } + 90% { opacity: 1; } + 100% { transform: translateY(110vh); opacity: 0; } +} + +@keyframes sports-sway { + 0% { transform: rotate(-15deg) translateX(-10px); } + 100% { transform: rotate(15deg) translateX(10px); } +} + +@keyframes sports-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(var(--spin-rot, 360deg)); } +} + @keyframes sports-confetti-fall { 0% { - transform: translateY(-5vh) rotateX(0deg) rotateY(0deg); + transform: translateY(-5vh) rotate3d(var(--rx), var(--ry), var(--rz), 0deg); opacity: 0; } 5% { @@ -75,7 +127,25 @@ opacity: 1; } 100% { - transform: translateY(105vh) rotateX(720deg) rotateY(360deg); + transform: translateY(105vh) rotate3d(var(--rx), var(--ry), var(--rz), var(--rot-dir)); opacity: 0; } } + +@keyframes sports-arc-x-right { + 0% { transform: translateX(0); } + 100% { transform: translateX(130vw); } +} + +@keyframes sports-arc-x-left { + 0% { transform: translateX(0); } + 100% { transform: translateX(-130vw); } +} + +@keyframes sports-arc-y { + 0% { transform: translateY(110vh) scale(0.5) rotate(-30deg); opacity: 0; animation-timing-function: ease-out; } + 5% { opacity: 1; } + 50% { transform: translateY(10vh) scale(1.5) rotate(0deg); animation-timing-function: ease-in; } + 95% { opacity: 1; } + 100% { transform: translateY(110vh) scale(0.5) rotate(30deg); opacity: 0; } +} diff --git a/Jellyfin.Plugin.Seasonals/Web/sports.js b/Jellyfin.Plugin.Seasonals/Web/sports.js index d7c4713..f05b6e3 100644 --- a/Jellyfin.Plugin.Seasonals/Web/sports.js +++ b/Jellyfin.Plugin.Seasonals/Web/sports.js @@ -1,11 +1,29 @@ const config = window.SeasonalsPluginConfig?.Sports || {}; const sports = config.EnableSports !== undefined ? config.EnableSports : true; -const symbolCount = config.SymbolCount || 25; +const symbolCount = config.SymbolCount || 5; const useRandomSymbols = config.EnableRandomSymbols !== undefined ? config.EnableRandomSymbols : true; const enableRandomMobile = config.EnableRandomSymbolsMobile !== undefined ? config.EnableRandomSymbolsMobile : false; const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; +// Pre-declare and manage image assets +const SPORTS_ASSETS = { + badminton: ['badminton_1', 'badminton_2'], + baseball: ['baseball_1', 'baseball_2'], + basketball: ['basketball_1', 'basketball_2'], + billiard: Array.from({length: 14}, (_, i) => `billiard_ball_${i + 1}`), + bowling: ['bowling_1', 'bowling_2'], + football: Array.from({length: 5}, (_, i) => `football_${i + 1}`), + golf: ['golf_ball_1', 'golf_ball_2'], + rugby: ['rugby_ball_1', 'rugby_ball_2'], + table_tennis: ['table_tennis_ball_1', 'table_tennis_ball_2'], + tennis: ['tennis_ball_1', 'tennis_ball_2'], + volleyball: ['volleyball_1', 'volleyball_2'], + waterball: ['waterball_1', 'waterball_2'] +}; + +const turfColorHex = config.TurfColor || '#228b22'; + let msgPrinted = false; function toggleSports() { @@ -48,9 +66,12 @@ function createSports() { document.body.appendChild(container); } - // Create a turf/grass overlay at the bottom + // Parse turf color config + // Create a turf/grass overlay at the bottom using the provided hex const turf = document.createElement('div'); turf.className = 'sports-turf'; + // Using hex with transparency (e.g., 4D = 30%, CC = 80%) + turf.style.background = `linear-gradient(180deg, transparent 0%, ${turfColorHex}4D 30%, ${turfColorHex}CC 100%)`; container.appendChild(turf); const standardCount = 15; @@ -65,27 +86,64 @@ function createSports() { const useRandomDuration = enableDifferentDuration !== false; - // Standard sports items to spawn - const activeItems = ['soccer', 'football', 'yellow_card', 'red_card', 'trophy']; + // Map standard sports balls to spawn based on category configuration + const rawSportsBalls = config.SportsBalls || 'football,basketball,tennis,volleyball'; + const chosenCategories = rawSportsBalls.split(',').map(s => s.trim()).filter(s => s !== ''); + + // Assemble activeItems from categories + let activeItems = []; + chosenCategories.forEach(category => { + if (SPORTS_ASSETS[category]) { + activeItems.push(...SPORTS_ASSETS[category]); + } else { + // Legacy fallback (in case older explicit filenames remain in config string) + activeItems.push(category); + } + }); - // Create falling sports items + if (activeItems.length === 0) activeItems.push(...SPORTS_ASSETS['football']); // fallback + + // Track items we still need to show at least once + let guaranteedItems = [...activeItems]; + + // Create falling sports balls for (let i = 0; i < finalCount; i++) { let symbol = document.createElement('div'); - // Randomly pick an item - const randomItem = activeItems[Math.floor(Math.random() * activeItems.length)]; + // Pick a guaranteed ball first, otherwise pick completely randomly + let randomItem; + if (guaranteedItems.length > 0) { + const index = Math.floor(Math.random() * guaranteedItems.length); + randomItem = guaranteedItems[index]; + guaranteedItems.splice(index, 1); + } else { + randomItem = activeItems[Math.floor(Math.random() * activeItems.length)]; + } + symbol.className = `sports-symbol sports-${randomItem}`; + // Create inner div for spinning rotation + let innerDiv = document.createElement('div'); + innerDiv.className = 'sports-inner'; + // Try load image let img = document.createElement('img'); - img.src = `../Seasonals/Resources/sports_images/${randomItem}.png`; + img.src = `../Seasonals/Resources/sport_assets/${randomItem}.png`; img.onerror = function() { this.style.display = 'none'; // hide broken image icon this.parentElement.innerHTML = getEmojiFallback(randomItem); // inject emoji fallback }; - symbol.appendChild(img); + innerDiv.appendChild(img); + + // Balls should bounce infinitely + symbol.style.animationName = 'sports-bounce'; + symbol.style.animationIterationCount = 'infinite'; + innerDiv.style.animationName = 'sports-spin'; + innerDiv.style.animationIterationCount = 'infinite'; + + symbol.appendChild(innerDiv); - const leftPos = Math.random() * 100; + const leftPos = Math.random() * 95; const delaySeconds = Math.random() * 10; let durationSeconds = 8; @@ -93,10 +151,13 @@ function createSports() { durationSeconds = Math.random() * 4 + 6; // 6 to 10 seconds } - // Add a random slight rotation difference - const startRot = Math.random() * 360; - symbol.style.setProperty('--start-rot', `${startRot}deg`); - symbol.style.setProperty('--end-rot', `${startRot + (Math.random() > 0.5 ? 360 : -360)}deg`); + // Add a random spin + const spinRot = (Math.random() > 0.5 ? 360 : -360) + "deg"; + innerDiv.style.setProperty('--spin-rot', spinRot); + + // Duration for the spin should be different from fall to look natural + const spinDuration = Math.random() * 2 + 2; + innerDiv.style.animationDuration = `${spinDuration}s`; symbol.style.left = `${leftPos}vw`; symbol.style.animationDuration = `${durationSeconds}s`; @@ -104,6 +165,66 @@ function createSports() { container.appendChild(symbol); } + + // Create the periodic flying trophy arc + function launchTrophy() { + if (!document.querySelector('.sports-container')) return; + + const flyFromLeft = Math.random() > 0.5; + let trophySymbol = document.createElement('div'); + trophySymbol.className = "sports-symbol sports-trophy-wrapper"; + + let trophyInner = document.createElement('div'); + trophyInner.className = "sports-inner sports-trophy-inner"; + + let trophyImg = document.createElement('img'); + trophyImg.src = `../Seasonals/Resources/sport_assets/trophy.gif`; + // Randomly scale trophy slightly larger + trophyImg.style.transform = `scale(${Math.random() * 0.5 + 0.8})`; + trophyImg.onerror = function() { + this.style.display = 'none'; + }; + + trophyInner.appendChild(trophyImg); + trophySymbol.appendChild(trophyInner); + + if (flyFromLeft) { + trophySymbol.style.animationName = "sports-arc-x-right"; + trophySymbol.style.left = "-15vw"; + } else { + trophySymbol.style.animationName = "sports-arc-x-left"; + trophySymbol.style.left = "115vw"; + } + + trophyInner.style.animationName = "sports-arc-y"; + + // Appearance timing + const arcDuration = 6 + Math.random() * 2; + + trophySymbol.style.animationDuration = `${arcDuration}s`; + trophyInner.style.animationDuration = `${arcDuration}s`; + + // Prevent looping for the trophy + trophySymbol.style.animationIterationCount = "1"; + trophyInner.style.animationIterationCount = "1"; + trophySymbol.style.animationFillMode = "forwards"; + trophyInner.style.animationFillMode = "forwards"; + + container.appendChild(trophySymbol); + + // Remove node after animation completes + setTimeout(() => { + if (trophySymbol && trophySymbol.parentNode) { + trophySymbol.parentNode.removeChild(trophySymbol); + } + }, arcDuration * 1000 + 500); + + // Schedule the next trophy + setTimeout(launchTrophy, Math.random() * 20000 + 10000); // Wait 10-30s until next trophy + } + + // Launch initial trophy after a short delay + setTimeout(launchTrophy, Math.random() * 5000 + 2000); // Add Germany Colored confetti (Black, Red, Gold) const confettiColors = ['#000000', '#FF0000', '#FFCC00']; @@ -116,6 +237,23 @@ function createSports() { const color = confettiColors[Math.floor(Math.random() * confettiColors.length)]; confetti.style.backgroundColor = color; + // Random shape generator for varied confetti + const shape = Math.random(); + if (shape > 0.66) { + confetti.classList.add('circle'); + const size = Math.random() * 5 + 5; // 5-10px + confetti.style.width = `${size}px`; + confetti.style.height = `${size}px`; + } else if (shape > 0.33) { + confetti.classList.add('rect'); + const width = Math.random() * 4 + 4; // 4-8px + const height = Math.random() * 5 + 8; // 8-13px + confetti.style.width = `${width}px`; + confetti.style.height = `${height}px`; + } else { + confetti.classList.add('triangle'); + } + const leftPos = Math.random() * 100; const delaySeconds = Math.random() * 8; const duration = Math.random() * 3 + 4; // 4 to 7 seconds @@ -123,17 +261,29 @@ function createSports() { confetti.style.left = `${leftPos}vw`; confetti.style.animationDuration = `${duration}s`; confetti.style.animationDelay = `${delaySeconds}s`; + + // Random 3D Rotation for flutter + confetti.style.setProperty('--rx', Math.random().toFixed(2)); + confetti.style.setProperty('--ry', Math.random().toFixed(2)); + confetti.style.setProperty('--rz', (Math.random() * 0.5).toFixed(2)); + confetti.style.setProperty('--rot-dir', `${(Math.random() > 0.5 ? 1 : -1) * 360}deg`); container.appendChild(confetti); } } function getEmojiFallback(type) { - if (type === 'soccer') return '⚽'; - if (type === 'football') return '🏈'; - if (type === 'yellow_card') return '🟨'; - if (type === 'red_card') return '🟥'; - if (type === 'trophy') return '🏆'; + if (type.includes('soccer') || type.includes('football')) return '⚽'; + if (type.includes('baseball')) return '⚾'; + if (type.includes('basketball')) return '🏀'; + if (type.includes('billiard')) return '🎱'; + if (type.includes('bowling')) return '🎳'; + if (type.includes('golf')) return '⛳'; + if (type.includes('rugby')) return '🏈'; + if (type.includes('tennis')) return '🎾'; + if (type.includes('volleyball')) return '🏐'; + if (type.includes('badminton')) return '🏸'; + if (type.includes('waterball')) return '🤽'; return ''; }