Compare commits

..

46 Commits

Author SHA1 Message Date
CodeDevMLH
45c9a199c2 Update manifest.json for release v1.7.1.1 [skip ci] 2026-02-19 18:00:57 +00:00
CodeDevMLH
1df6fb37b1 now?
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 55s
2026-02-19 18:51:28 +01:00
CodeDevMLH
82a1e8a178 Refactor RemoveLegacyTags method to include modification tracking and update logging
Some checks failed
Auto Release Plugin / build-and-release (push) Failing after 12s
2026-02-19 18:36:08 +01:00
CodeDevMLH
22bf887d10 Bump version to 1.7.1.1
Some checks failed
Auto Release Plugin / build-and-release (push) Failing after 52s
2026-02-19 18:31:49 +01:00
CodeDevMLH
07600766cf Add legacy tag removal functionality to ScriptInjector 2026-02-19 18:31:10 +01:00
CodeDevMLH
56298487f4 Update manifest.json for release v1.7.1.0 [skip ci] 2026-02-19 02:20:24 +00:00
CodeDevMLH
89fc1c38f0 Update version to 1.7.1.0
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 53s
2026-02-19 03:19:31 +01:00
CodeDevMLH
4c168a5ec2 Add Spring, Summer, and Carnival options to plugin configuration and UI 2026-02-19 03:18:39 +01:00
CodeDevMLH
92d9e1a9ad Add spring, summer, and carnival themes to the theme selector 2026-02-19 03:18:32 +01:00
CodeDevMLH
007e55a612 Refactor summer object creation to use configurable bubble and dust counts 2026-02-19 03:18:26 +01:00
CodeDevMLH
20da9899e4 Fix typo in resurrection CSS class and animation names 2026-02-19 03:18:20 +01:00
CodeDevMLH
9b9cad1caa Add carnival animation effects with CSS and JavaScript implementation 2026-02-19 03:18:13 +01:00
CodeDevMLH
e8e3424cc9 Add spring animation effects with CSS and JavaScript implementation 2026-02-19 03:18:06 +01:00
CodeDevMLH
0eeed99508 Add summer animation effects with CSS and JavaScript implementation 2026-02-19 02:22:54 +01:00
CodeDevMLH
a0f261f597 Update CONTRIBUTING.md to enhance clarity on testing themes with test-site.html [skip ci] 2026-02-17 14:23:03 +01:00
CodeDevMLH
35d92862aa Update manifest.json for release v1.7.0.15 [skip ci] 2026-02-17 13:11:22 +00:00
CodeDevMLH
693bb35aac Bump version to 1.7.0.15 and update embedded resources in project files
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 55s
2026-02-17 14:10:26 +01:00
CodeDevMLH
1ddaab325e Update CONTRIBUTING.md for clarity and consistency in theme development guidelines 2026-02-17 14:10:19 +01:00
CodeDevMLH
81facbdb00 Update README.md to reference contributing guidelines 2026-02-17 14:10:13 +01:00
CodeDevMLH
34a58ac4bd Add test-site.html for seasonal theme testing interface 2026-02-17 14:10:07 +01:00
CodeDevMLH
2d8444701d Remove test-site.html and test-site-new.html from .gitignore 2026-02-17 14:09:58 +01:00
CodeDevMLH
66f5353659 Add input fields for custom JavaScript and CSS in the control panel 2026-02-17 13:47:43 +01:00
CodeDevMLH
b58264998a Enhance theme loading by tracking animation frames, MutationObservers, and intervals; wrap JS in IIFE for scope isolation
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 37s
2026-02-17 02:01:05 +01:00
CodeDevMLH
76c0bc5b3b Add CONTRIBUTING.md for theme development guidelines and update test site styles
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 36s
2026-02-17 01:58:58 +01:00
CodeDevMLH
1428db3e1e Update manifest.json for release v1.7.0.14 [skip ci] 2026-02-16 23:02:32 +00:00
CodeDevMLH
1f5f436e44 Bump version to 1.7.0.14
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 52s
2026-02-17 00:01:41 +01:00
CodeDevMLH
46f5c3648d Refactor seasonal rule HTML generation for improved maintainability 2026-02-17 00:01:21 +01:00
CodeDevMLH
555e2ab8be Update manifest.json for release v1.7.0.13 [skip ci] 2026-02-16 22:37:20 +00:00
CodeDevMLH
26eadfc0aa Bump version to 1.7.0.13
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 51s
2026-02-16 23:36:26 +01:00
CodeDevMLH
142f538939 Enhance select elements in date range group with consistent styling 2026-02-16 23:36:08 +01:00
CodeDevMLH
b64e80fd60 Update manifest.json for release v1.7.0.12 [skip ci] 2026-02-16 22:18:09 +00:00
CodeDevMLH
fbf5fc7edf Bump version to 1.7.0.12
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 52s
2026-02-16 23:17:17 +01:00
CodeDevMLH
8defba4623 Refactor date selection logic in SeasonalsConfigPage to use helper function for generating options 2026-02-16 23:17:02 +01:00
CodeDevMLH
7f968ee050 Update manifest.json for release v1.7.0.11 [skip ci] 2026-02-16 22:06:11 +00:00
CodeDevMLH
dec5bbe39e Bump version to 1.7.0.11
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 52s
2026-02-16 23:05:18 +01:00
CodeDevMLH
63f3211cc4 Refactor date selection options to use SeasonalsConfigPage methods for consistency 2026-02-16 23:05:06 +01:00
CodeDevMLH
4270235c78 Update manifest.json for release v1.7.0.10 [skip ci] 2026-02-16 22:02:53 +00:00
CodeDevMLH
76d8a67914 Bump version to 1.7.0.10
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 52s
2026-02-16 23:02:00 +01:00
CodeDevMLH
1a3caf5da6 Enhance configuration page: replace input fields with dropdowns for date selection and add utility functions for generating options 2026-02-16 23:01:46 +01:00
CodeDevMLH
3b3ef77e61 fix ui [skip ci] 2026-02-16 19:31:42 +01:00
CodeDevMLH
ba580b1b52 Update manifest.json for release v1.7.0.9 [skip ci] 2026-02-16 18:27:34 +00:00
CodeDevMLH
0a6284c716 Bump version to 1.7.0.9
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 51s
2026-02-16 19:26:43 +01:00
CodeDevMLH
f83e863664 .. 2026-02-16 19:26:26 +01:00
CodeDevMLH
747e8ed6bc Update manifest.json for release v1.7.0.8 [skip ci] 2026-02-16 18:01:43 +00:00
CodeDevMLH
30845442b2 Bump version to 1.7.0.8
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 53s
2026-02-16 19:00:51 +01:00
CodeDevMLH
bb83201736 .. 2026-02-16 18:59:28 +01:00
19 changed files with 2384 additions and 166 deletions

3
.gitignore vendored
View File

@@ -4,6 +4,5 @@ obj/
.idea/ .idea/
artifacts artifacts
test-site.html test-site-old.html
test-site-new.html
RELEASE_GUIDE.md RELEASE_GUIDE.md

343
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,343 @@
# Contributing to Jellyfin Seasonals Plugin
Thank you for your interest in contributing seasonal themes to the Jellyfin Seasonals Plugin! This guide explains how seasonal themes are structured, how to create your own, and how to test them locally before submitting a pull request.
---
## Table of Contents
- [Contributing to Jellyfin Seasonals Plugin](#contributing-to-jellyfin-seasonals-plugin)
- [Table of Contents](#table-of-contents)
- [Theme Architecture Overview](#theme-architecture-overview)
- [Standard Theme File Structure](#standard-theme-file-structure)
- [JavaScript File Pattern](#javascript-file-pattern)
- [Key Rules](#key-rules)
- [CSS File Pattern](#css-file-pattern)
- [Key Rules](#key-rules-1)
- [Image Assets (Optional)](#image-assets-optional)
- [Registering Your Theme](#registering-your-theme)
- [1. `seasonals.js` — Client-Side Registration](#1-seasonalsjs--client-side-registration)
- [2. `PluginConfiguration.cs` and `configPage.html` - Server-Side Registration](#2-pluginconfigurationcs-and-configpagehtml---server-side-registration)
- [Testing Your Theme Locally](#testing-your-theme-locally)
- [Steps](#steps)
- [What to Verify](#what-to-verify)
- [Submitting Your Contribution](#submitting-your-contribution)
- [Pull Request Checklist](#pull-request-checklist)
- [PR Description Template](#pr-description-template)
- [GitHub Issue Template for Theme Ideas](#github-issue-template-for-theme-ideas)
- [Questions?](#questions)
---
## Theme Architecture Overview
Each seasonal theme consists of **23 components** that live in `Jellyfin.Plugin.Seasonals/Web/`:
| Component | File(s) | Purpose |
| :--- | :--- | :--- |
| **JavaScript** | `{themeName}.js` | Animation logic, DOM manipulation, element creation |
| **CSS** | `{themeName}.css` | Container styling, element appearance, keyframe animations |
| **Images** *(optional)* | `{themeName}_images/` | Image assets (PNGs, SVGs) used by the theme |
The orchestrator file `seasonals.js` manages theme loading at runtime. It reads the plugin configuration, determines which theme should be active, and dynamically injects the correct CSS and JS files.
---
## Standard Theme File Structure
Here is a complete file layout for a theme called `mytheme`:
```
Jellyfin.Plugin.Seasonals/
└── Web/
├── mytheme.js # Animation/DOM logic
├── mytheme.css # Styles & animations
├── mytheme_images/ # (Optional) image assets
│ ├── sprite1.png
│ └── sprite2.png
└── seasonals.js # (Existing) Add your theme to ThemeConfigs
```
---
## JavaScript File Pattern
Every theme JS file follows a **consistent skeleton**. Use this as your starting template:
```javascript
// 1. Read Configuration
const config = window.SeasonalsPluginConfig?.MyTheme || {};
const enabled = config.EnableMyTheme !== undefined ? config.EnableMyTheme : true;
const elementCount = config.ElementCount || 25;
// ... add more config options as needed
let msgPrinted = false;
// 2. Toggle Function
// Hides the effect when a video player, trailer (in full width mode), dashboard, or user menu is active.
function toggleMyTheme() {
const container = document.querySelector('.mytheme-container');
if (!container) 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');
if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) {
container.style.display = 'none';
if (!msgPrinted) {
console.log('MyTheme hidden');
msgPrinted = true;
}
} else {
container.style.display = 'block';
if (msgPrinted) {
console.log('MyTheme visible');
msgPrinted = false;
}
}
}
// 3. MutationObserver
// Watches the DOM for changes so the effect can auto-hide/show.
const observer = new MutationObserver(toggleMyTheme);
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true
});
// 4. Element Creation
// Create and append your animated elements to the container.
function createElements() {
const container = document.querySelector('.mytheme-container') || document.createElement('div');
if (!document.querySelector('.mytheme-container')) {
container.className = 'mytheme-container';
container.setAttribute('aria-hidden', 'true');
document.body.appendChild(container);
}
for (let i = 0; i < elementCount; i++) {
const el = document.createElement('div');
el.className = 'mytheme-element';
// Set random position, delay, duration, etc.
el.style.left = `${Math.random() * 100}%`;
el.style.animationDelay = `${Math.random() * 10}s, ${Math.random() * 4}s`;
// If using images:
// const img = document.createElement('img');
// img.src = '../Seasonals/Resources/mytheme_images/sprite1.png';
// el.appendChild(img);
// If using text/emoji:
// el.textContent = '⭐';
container.appendChild(el);
}
}
// 5. Initialization
function initializeMyTheme() {
if (!enabled) return;
createElements();
toggleMyTheme();
}
initializeMyTheme();
```
### Key Rules
- **Always** read config from `window.SeasonalsPluginConfig?.{ThemeName}`.
- **Always** implement the toggle function with the same selectors (`.videoPlayerContainer`, `.youtubePlayerContainer`, `.dashboardDocument`, `#app-user-menu`, just use the above template).
- **Always** use `aria-hidden="true"` on the container for accessibility.
- Call your `initialize` function at the end of the file.
- For **canvas-based** themes (like `snowfall.js`), use a `<canvas>` element with `requestAnimationFrame` instead of CSS animations. Make sure to clean up with `cancelAnimationFrame` when hidden.
---
## CSS File Pattern
Every theme CSS file follows this structure:
```css
/* Container */
/* Full-screen overlay, transparent, non-interactive */
.mytheme-container {
display: block;
position: fixed;
overflow: hidden;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* IMPORTANT: don't block user interaction */
z-index: 10;
}
/* Animated Element */
.mytheme-element {
position: fixed;
z-index: 15;
user-select: none;
cursor: default;
/* Two animations: movement + secondary effect (shake, rotate, etc.) */
animation-name: mytheme-fall, mytheme-shake;
animation-duration: 10s, 3s;
animation-timing-function: linear, ease-in-out;
animation-iteration-count: infinite, infinite;
}
/* Keyframes */
@keyframes mytheme-fall {
0% { top: -10%; }
100% { top: 100%; }
}
@keyframes mytheme-shake {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(80px); }
}
/* Staggered Delays for Base Elements */
/* Spread the initial 12 elements across the screen */
.mytheme-element:nth-of-type(1) { left: 10%; animation-delay: 1s, 1s; }
.mytheme-element:nth-of-type(2) { left: 20%; animation-delay: 6s, 0.5s; }
.mytheme-element:nth-of-type(3) { left: 30%; animation-delay: 4s, 2s; }
/* ... continue for each base element */
```
### Key Rules
- **Container** must be `position: fixed`, full-screen, with `pointer-events: none` and at least `z-index: 10`.
- **Elements** should use `position: fixed` with at least `z-index: 15`.
- Use **animations** (eg. primary movement + secondary effect for natural-looking motion).
- Include **`nth-of-type` rules** for the initial set of base elements to stagger them.
- Include **webkit prefixes** (`-webkit-animation-*`, `@-webkit-keyframes`) for broader compatibility (see existing themes for examples).
---
## Image Assets (Optional)
If your theme uses images (e.g., leaves, ghosts, eggs):
1. Create a folder: `Jellyfin.Plugin.Seasonals/Web/{themeName}_images/`
2. Place your assets inside (PNG recommended, keep files small)
3. Reference them in JS using the production path:
```javascript
img.src = '../Seasonals/Resources/mytheme_images/sprite1.png';
```
---
## Registering Your Theme
After creating your JS and CSS files, you need to register the theme in two places:
### 1. `seasonals.js` — Client-Side Registration
Add your theme to the `ThemeConfigs` object:
```javascript
const ThemeConfigs = {
// ... existing themes ...
mytheme: {
css: '../Seasonals/Resources/mytheme.css',
js: '../Seasonals/Resources/mytheme.js',
containerClass: 'mytheme-container'
},
// ...
};
```
### 2. `PluginConfiguration.cs` and `configPage.html` - Server-Side Registration
> [!NOTE]
> The backend registration is handled by the plugin maintainers. You do **not** need to modify C# files for your theme submission. Just focus on the JS/CSS/images.
>
> However, if you'd like to include full backend integration, add your theme to the enum/configuration in `Configuration/PluginConfiguration.cs` and the selectors in `configPage.html`.
---
## Testing Your Theme Locally
You can test your theme without a Jellyfin server by using the included test site.
### Steps
1. Navigate to the `Jellyfin.Plugin.Seasonals/Web/` directory
2. Open [`test-site.html`](./Jellyfin.Plugin.Seasonals/Web/test-site.html) in your browser (just double-click the file) or vscode or what ever you use...
3. Use the **theme selector dropdown** to pick an existing theme or select **"Custom (Local Files)"** to test your own
4. When "Custom" is selected, enter your theme's JS and CSS filenames (e.g., `mytheme.js` and `mytheme.css` (must be in the same folder as [`test-site.html`](./Jellyfin.Plugin.Seasonals/Web/test-site.html) for this to work))
5. Click **"Load Theme"** to apply. Click **"Clear & Reload"** to reset and try again
### What to Verify
- ✅ The effect is visible on the background
- ✅ The animation runs smoothly
- ✅ Elements are spread across the full viewport
- ✅ The mock header is **not blocked** by the effect (thanks to `pointer-events: none`)
- ✅ No theme related console errors appear (check DevTools → Console)
---
## Submitting Your Contribution
### Pull Request Checklist
- [ ] Created `{themeName}.js` following the [JS pattern](#javascript-file-pattern)
- [ ] Created `{themeName}.css` following the [CSS pattern](#css-file-pattern)
- [ ] (If applicable) Created `{themeName}_images/` with optimized assets
- [ ] Added theme to `ThemeConfigs` in `seasonals.js`
- [ ] Tested locally with [`test-site.html`](./Jellyfin.Plugin.Seasonals/Web/test-site.html)
- [ ] No theme related console errors
- [ ] Effect has `pointer-events: none` (doesn't block the UI)
- [ ] Effect hides during video/trailer playback (toggle function implemented)
- [ ] (Optional) Included a screenshot or short recording of the effect to the readme
### PR Description Template
```
## New Seasonal Theme: {Theme Name}
**Description:** Brief description of the theme and what occasion/season it's for.
**Screenshot / Recording:**
[Attach a screenshot or GIF showcasing the theme in action]
**Testing:**
- Tested locally with test-site-new.html ✅
- No console errors ✅
- pointer-events: none verified ✅
```
---
## GitHub Issue Template for Theme Ideas
If you have an idea for a seasonal theme but don't want to implement it yourself, feel free to open an issue using the following template:
**Title:** `[Theme Idea] {Season/Holiday Name} Theme`
**Body:**
```
## 🎨 Theme Idea: {Season/Holiday Name}
**Occasion/Season:** What time of year is this for?
**Description:** Describe the visual effect you have in mind.
**Visual References:** Links to images, GIFs, or videos that capture the aesthetic.
**Suggested Active Period:** e.g. "March 1 March 17" for St. Patrick's Day
```
---
## Questions?
If you have any questions about contributing, feel free to open an issue. Happy theming! 🎉

207
Injector_new.cs Normal file
View File

@@ -0,0 +1,207 @@
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using MediaBrowser.Common.Configuration;
using Jellyfin.Plugin.Seasonals.Helpers;
using System.Collections.Generic;
using System.Linq;
namespace Jellyfin.Plugin.Seasonals;
/// <summary>
/// Handles the injection of the Seasonals script into the Jellyfin web interface.
/// </summary>
public class ScriptInjector
{
private readonly IApplicationPaths _appPaths;
private readonly ILogger<ScriptInjector> _logger;
public const string ScriptTag = "<script src=\"../Seasonals/Resources/seasonals.js\" defer></script>";
public const string Marker = "</body>";
/// <summary>
/// Initializes a new instance of the <see cref="ScriptInjector"/> class.
/// </summary>
/// <param name="appPaths">The application paths.</param>
/// <param name="logger">The logger.</param>
public ScriptInjector(IApplicationPaths appPaths, ILogger<ScriptInjector> logger)
{
_appPaths = appPaths;
_logger = logger;
}
/// <summary>
/// Injects the script tag into index.html if it's not already present.
/// </summary>
public void Inject()
{
try
{
var webPath = GetWebPath();
if (string.IsNullOrEmpty(webPath))
{
_logger.LogWarning("Could not find Jellyfin web path. Script injection skipped. Attempting fallback.");
RegisterFileTransformation();
return;
}
var indexPath = Path.Combine(webPath, "index.html");
if (!File.Exists(indexPath))
{
_logger.LogWarning("index.html not found at {Path}. Script injection skipped. Attempting fallback.", indexPath);
RegisterFileTransformation();
return;
}
var content = File.ReadAllText(indexPath);
if (!content.Contains(ScriptTag))
{
var index = content.IndexOf(Marker, StringComparison.OrdinalIgnoreCase);
if (index != -1)
{
content = content.Insert(index, ScriptTag + Environment.NewLine);
File.WriteAllText(indexPath, content);
_logger.LogInformation("Successfully injected Seasonals script into index.html.");
}
else
{
_logger.LogWarning("Script already present in index.html. Or could not be injected.");
}
}
}
catch (UnauthorizedAccessException)
{
_logger.LogWarning("Unauthorized access when attempting to inject script into index.html. Automatic injection failed. Attempting fallback now...");
RegisterFileTransformation();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error injecting Seasonals script. Attempting fallback.");
RegisterFileTransformation();
}
}
/// <summary>
/// Removes the script tag from index.html.
/// </summary>
public void Remove()
{
UnregisterFileTransformation();
try
{
var webPath = GetWebPath();
if (string.IsNullOrEmpty(webPath))
{
return;
}
var indexPath = Path.Combine(webPath, "index.html");
if (!File.Exists(indexPath))
{
return;
}
var content = File.ReadAllText(indexPath);
if (content.Contains(ScriptTag))
{
content = content.Replace(ScriptTag + Environment.NewLine, "").Replace(ScriptTag, "");
File.WriteAllText(indexPath, content);
_logger.LogInformation("Successfully removed Seasonals script from index.html.");
} else {
_logger.LogInformation("Seasonals script tag not found in index.html. No removal necessary.");
}
}
catch (UnauthorizedAccessException)
{
_logger.LogWarning("Unauthorized access when attempting to remove script from index.html.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing Seasonals script.");
}
}
/// <summary>
/// Retrieves the path to the Jellyfin web interface directory.
/// </summary>
/// <returns>The path to the web directory, or null if not found.</returns>
private string? GetWebPath()
{
// Use reflection to access WebPath property to ensure compatibility across different Jellyfin versions
var prop = _appPaths.GetType().GetProperty("WebPath", BindingFlags.Instance | BindingFlags.Public);
return prop?.GetValue(_appPaths) as string;
}
private void RegisterFileTransformation()
{
_logger.LogInformation("Seasonals Fallback. Registering file transformations.");
List<JObject> payloads = new List<JObject>();
{
JObject payload = new JObject();
payload.Add("id", "ef1e863f-cbb0-4e47-9f23-f0cbb1826ad4");
payload.Add("fileNamePattern", "index.html");
payload.Add("callbackAssembly", GetType().Assembly.FullName);
payload.Add("callbackClass", typeof(TransformationPatches).FullName);
payload.Add("callbackMethod", nameof(TransformationPatches.IndexHtml));
payloads.Add(payload);
}
Assembly? fileTransformationAssembly =
AssemblyLoadContext.All.SelectMany(x => x.Assemblies).FirstOrDefault(x =>
x.FullName?.Contains(".FileTransformation") ?? false);
if (fileTransformationAssembly != null)
{
Type? pluginInterfaceType = fileTransformationAssembly.GetType("Jellyfin.Plugin.FileTransformation.PluginInterface");
if (pluginInterfaceType != null)
{
foreach (JObject payload in payloads)
{
pluginInterfaceType.GetMethod("RegisterTransformation")?.Invoke(null, new object?[] { payload });
}
_logger.LogInformation("File transformations registered successfully.");
}
else
{
_logger.LogWarning("FileTransformation plugin found but PluginInterface type missing.");
}
}
else
{
_logger.LogWarning("FileTransformation plugin assembly not found. Fallback injection skipped.");
}
}
private void UnregisterFileTransformation()
{
try
{
Assembly? fileTransformationAssembly =
AssemblyLoadContext.All.SelectMany(x => x.Assemblies).FirstOrDefault(x =>
x.FullName?.Contains(".FileTransformation") ?? false);
if (fileTransformationAssembly != null)
{
Type? pluginInterfaceType = fileTransformationAssembly.GetType("Jellyfin.Plugin.FileTransformation.PluginInterface");
if (pluginInterfaceType != null)
{
Guid id = Guid.Parse("ef1e863f-cbb0-4e47-9f23-f0cbb1826ad4");
pluginInterfaceType.GetMethod("RemoveTransformation")?.Invoke(null, new object?[] { id });
_logger.LogInformation("File transformation unregistered successfully.");
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error attempting to unregister file transformation. It might not have been registered.");
}
}
}

View File

@@ -28,6 +28,10 @@ public class PluginConfiguration : BasePluginConfiguration
Santa = new SantaOptions(); Santa = new SantaOptions();
Easter = new EasterOptions(); Easter = new EasterOptions();
Resurrection = new ResurrectionOptions(); Resurrection = new ResurrectionOptions();
Spring = new SpringOptions();
Summer = new SummerOptions();
CherryBlossom = new CherryBlossomOptions();
Carnival = new CarnivalOptions();
} }
/// <summary> /// <summary>
@@ -53,7 +57,7 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets the seasonal rules configuration as JSON. /// Gets or sets the seasonal rules configuration as JSON.
/// </summary> /// </summary>
public string SeasonalRules { get; set; } = "[{\"Name\":\"New Year Fireworks\",\"StartDay\":28,\"StartMonth\":12,\"EndDay\":5,\"EndMonth\":1,\"Theme\":\"fireworks\"},{\"Name\":\"Valentine's Day\",\"StartDay\":10,\"StartMonth\":2,\"EndDay\":18,\"EndMonth\":2,\"Theme\":\"hearts\"},{\"Name\":\"Santa\",\"StartDay\":22,\"StartMonth\":12,\"EndDay\":27,\"EndMonth\":12,\"Theme\":\"santa\"},{\"Name\":\"Snowflakes (December)\",\"StartDay\":1,\"StartMonth\":12,\"EndDay\":31,\"EndMonth\":12,\"Theme\":\"snowflakes\"},{\"Name\":\"Snowfall (January)\",\"StartDay\":1,\"StartMonth\":1,\"EndDay\":31,\"EndMonth\":1,\"Theme\":\"snowfall\"},{\"Name\":\"Snowfall (February)\",\"StartDay\":1,\"StartMonth\":2,\"EndDay\":29,\"EndMonth\":2,\"Theme\":\"snowfall\"},{\"Name\":\"Easter\",\"StartDay\":25,\"StartMonth\":3,\"EndDay\":25,\"EndMonth\":4,\"Theme\":\"easter\"},{\"Name\":\"Halloween\",\"StartDay\":24,\"StartMonth\":10,\"EndDay\":5,\"EndMonth\":11,\"Theme\":\"halloween\"},{\"Name\":\"Autumn\",\"StartDay\":1,\"StartMonth\":9,\"EndDay\":30,\"EndMonth\":11,\"Theme\":\"autumn\"}]"; public string SeasonalRules { get; set; } = "[{\"Name\":\"New Year Fireworks\",\"StartDay\":28,\"StartMonth\":12,\"EndDay\":5,\"EndMonth\":1,\"Theme\":\"fireworks\"},{\"Name\":\"Carnival\",\"StartDay\":19,\"StartMonth\":2,\"EndDay\":28,\"EndMonth\":2,\"Theme\":\"carnival\"},{\"Name\":\"Valentine's Day\",\"StartDay\":10,\"StartMonth\":2,\"EndDay\":18,\"EndMonth\":2,\"Theme\":\"hearts\"},{\"Name\":\"Spring\",\"StartDay\":1,\"StartMonth\":3,\"EndDay\":31,\"EndMonth\":5,\"Theme\":\"spring\"},{\"Name\":\"Summer\",\"StartDay\":1,\"StartMonth\":6,\"EndDay\":31,\"EndMonth\":8,\"Theme\":\"summer\"},{\"Name\":\"Santa\",\"StartDay\":22,\"StartMonth\":12,\"EndDay\":27,\"EndMonth\":12,\"Theme\":\"santa\"},{\"Name\":\"Snowflakes (December)\",\"StartDay\":1,\"StartMonth\":12,\"EndDay\":31,\"EndMonth\":12,\"Theme\":\"snowflakes\"},{\"Name\":\"Snowfall (January)\",\"StartDay\":1,\"StartMonth\":1,\"EndDay\":31,\"EndMonth\":1,\"Theme\":\"snowfall\"},{\"Name\":\"Snowfall (February)\",\"StartDay\":1,\"StartMonth\":2,\"EndDay\":29,\"EndMonth\":2,\"Theme\":\"snowfall\"},{\"Name\":\"Easter\",\"StartDay\":25,\"StartMonth\":3,\"EndDay\":25,\"EndMonth\":4,\"Theme\":\"easter\"},{\"Name\":\"Halloween\",\"StartDay\":24,\"StartMonth\":10,\"EndDay\":5,\"EndMonth\":11,\"Theme\":\"halloween\"},{\"Name\":\"Autumn\",\"StartDay\":1,\"StartMonth\":9,\"EndDay\":30,\"EndMonth\":11,\"Theme\":\"autumn\"}]";
/// <summary> /// <summary>
/// Gets or sets the Seasonals options. /// Gets or sets the Seasonals options.
@@ -69,6 +73,10 @@ public class PluginConfiguration : BasePluginConfiguration
public SantaOptions Santa { get; set; } public SantaOptions Santa { get; set; }
public EasterOptions Easter { get; set; } public EasterOptions Easter { get; set; }
public ResurrectionOptions Resurrection { get; set; } public ResurrectionOptions Resurrection { get; set; }
public SpringOptions Spring { get; set; }
public SummerOptions Summer { get; set; }
public CherryBlossomOptions CherryBlossom { get; set; }
public CarnivalOptions Carnival { get; set; }
} }
public class AutumnOptions public class AutumnOptions
@@ -182,3 +190,42 @@ public class ResurrectionOptions
public bool EnableRandomSymbolsMobile { get; set; } = false; public bool EnableRandomSymbolsMobile { get; set; } = false;
public bool EnableDifferentDuration { get; set; } = true; public bool EnableDifferentDuration { get; set; } = true;
} }
public class SpringOptions
{
public int PollenCount { get; set; } = 15;
public int LadybugCount { get; set; } = 5;
public int SunbeamCount { get; set; } = 5;
public bool EnableSpring { get; set; } = true;
public bool EnableRandomSpring { get; set; } = true;
public bool EnableRandomSpringMobile { get; set; } = false;
public bool EnableDifferentDuration { get; set; } = true;
}
public class SummerOptions
{
public int BubbleCount { get; set; } = 20;
public int DustCount { get; set; } = 50;
public bool EnableSummer { get; set; } = true;
public bool EnableRandomSummer { get; set; } = true;
public bool EnableRandomSummerMobile { get; set; } = false;
public bool EnableDifferentDuration { get; set; } = true;
}
public class CarnivalOptions
{
public int ObjectCount { get; set; } = 25;
public bool EnableCarnival { get; set; } = true;
public bool EnableRandomCarnival { get; set; } = true;
public bool EnableRandomCarnivalMobile { get; set; } = false;
public bool EnableDifferentDuration { get; set; } = true;
}
public class CherryBlossomOptions
{
public int PetalCount { get; set; } = 25;
public bool EnableCherryBlossom { get; set; } = true;
public bool EnableRandomCherryBlossom { get; set; } = true;
public bool EnableRandomCherryBlossomMobile { get; set; } = false;
public bool EnableDifferentDuration { get; set; } = true;
}

View File

@@ -62,14 +62,13 @@
<option value="halloween">Halloween</option> <option value="halloween">Halloween</option>
<option value="hearts">Hearts</option> <option value="hearts">Hearts</option>
<option value="christmas">Christmas</option> <option value="christmas">Christmas</option>
<option value="santa">Santa</option> <option value="santa">Santa (flying santa & snowfall)</option>
<option value="autumn">Autumn</option> <option value="autumn">Autumn (falling leaves)</option>
<option value="easter">Easter</option> <option value="easter">Easter</option>
<option value="resurrection">Resurrection</option> <option value="resurrection">Resurrection</option>
<option value="summer" disabled>Summer (not implemented yet. Please commit ideas in a issue or PR)</option> <option value="summer">Summer (Bubbles)</option>
<option value="spring" disabled>Spring (not implemented yet. Please commit ideas in a issue or PR)</option> <option value="spring">Spring</option>
<option value="oktoberfest" disabled>Oktoberfest (not implemented yet. Please commit ideas in a issue or PR)</option> <option value="carnival">Carnival (Confetti)</option>
<option value="carnival" disabled>Carnival (not implemented yet. Please commit ideas in a issue or PR)</option>
<option value="championships" disabled>European/World Championships (not implemented yet. Please commit ideas in a issue or PR)</option> <option value="championships" disabled>European/World Championships (not implemented yet. Please commit ideas in a issue or PR)</option>
<option value="patrick" disabled>St. Patrick's Day (not implemented yet. Please commit ideas in a issue or PR)</option> <option value="patrick" disabled>St. Patrick's Day (not implemented yet. Please commit ideas in a issue or PR)</option>
<option value="thanksgiving" disabled>Thanksgiving (not implemented yet. Please commit ideas in a issue or PR)</option> <option value="thanksgiving" disabled>Thanksgiving (not implemented yet. Please commit ideas in a issue or PR)</option>
@@ -88,8 +87,8 @@
<!-- Auto Selection Tab --> <!-- Auto Selection Tab -->
<div id="seasonals-auto-selection" class="seasonals-tab-content" style="display: none;"> <div id="seasonals-auto-selection" class="seasonals-tab-content" style="display: none;">
<h3>Auto Selection Rules</h3> <h2>Auto Selection Rules</h2>
<div style="background-color: rgba(255, 255, 255, 0.05); border-left: 4px solid #00a4dc; border-radius: 4px; padding: 1em 1.5em; margin: 1.5em 0; display: flex; align-items: center; gap: 1em;"> <div style="background-color: rgba(255, 255, 255, 0.05); border-left: 4px solid #00a4dc; border-radius: 4px; padding: 1em 1.5em; margin: 1.5em 0; display: flex; align-items: center; gap: 1em;">
<i class="material-icons" style="color: #00a4dc; font-size: 24px;">info</i> <i class="material-icons" style="color: #00a4dc; font-size: 24px;">info</i>
<div>Define rules to automatically select a season based on the date. Rules are evaluated from top to bottom. The first matching rule wins.</div> <div>Define rules to automatically select a season based on the date. Rules are evaluated from top to bottom. The first matching rule wins.</div>
@@ -99,7 +98,8 @@
<!-- Rules will be injected here via JS --> <!-- Rules will be injected here via JS -->
</div> </div>
<button is="emby-button" type="button" class="raised button-accent block" onclick="SeasonalsConfigPage.addRule();"> <button is="emby-button" type="button" class="raised emby-button" onclick="SeasonalsConfigPage.addRule();"
style="margin-top: 1em; display: inline-flex; align-items: center; gap: 0.4em;">
<i class="material-icons" style="font-size: 24px;">add</i> <i class="material-icons" style="font-size: 24px;">add</i>
<span>Add Rule</span> <span>Add Rule</span>
</button> </button>
@@ -575,6 +575,140 @@
<div class="fieldDescription">Randomize the movement speed.</div> <div class="fieldDescription">Randomize the movement speed.</div>
</div> </div>
</details> </details>
<hr style="max-width: 800px; margin: 1em 0;">
<details>
<summary>Spring</summary>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableSpring" name="EnableSpring" type="checkbox" is="emby-checkbox" />
<span>Enable Spring Seasonal</span>
</label>
<div class="fieldDescription">Enable the Spring theme in general (e.g. for automation).</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableRandomSpring" name="EnableRandomSpring" type="checkbox" is="emby-checkbox" />
<span>Enable Additional Random Sakura Petals</span>
</label>
<div class="fieldDescription">Displays additional Sakura petals falling across the screen.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableRandomSpringMobile" name="EnableRandomSpringMobile" type="checkbox" is="emby-checkbox" />
<span>Enable Additional Random Sakura Petals on Mobile</span>
</label>
<div class="fieldDescription">Displays additional Sakura petals falling across the screen on mobile devices. Warning: High values may affect performance.</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="SpringPetalCount">Sakura Petal Count</label>
<input is="emby-input" type="number" id="SpringPetalCount" name="SpringPetalCount" />
<div class="fieldDescription">Number of additional Sakura petals (if enabled).</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="SpringPollenCount">Pollen Count</label>
<input is="emby-input" type="number" id="SpringPollenCount" name="SpringPollenCount" />
<div class="fieldDescription">Number of pollen particles (if enabled).</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="SpringLadybugCount">Ladybug Count</label>
<input is="emby-input" type="number" id="SpringLadybugCount" name="SpringLadybugCount" />
<div class="fieldDescription">Number of ladybugs.</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="SpringSunbeamCount">Sunbeam Count</label>
<input is="emby-input" type="number" id="SpringSunbeamCount" name="SpringSunbeamCount" />
<div class="fieldDescription">Number of sunbeams (if enabled).</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableDifferentDurationSpring" name="EnableDifferentDurationSpring" type="checkbox" is="emby-checkbox" />
<span>Enable Different Falling Speed</span>
</label>
<div class="fieldDescription">Randomize the falling speed of petals.</div>
</div>
</details>
<hr style="max-width: 800px; margin: 1em 0;">
<details>
<summary>Summer</summary>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableSummer" name="EnableSummer" type="checkbox" is="emby-checkbox" />
<span>Enable Summer Seasonal</span>
</label>
<div class="fieldDescription">Enable the Summer theme in general (e.g. for automation).</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableRandomSummer" name="EnableRandomSummer" type="checkbox" is="emby-checkbox" />
<span>Enable Additional Random Bubbles and Dust</span>
</label>
<div class="fieldDescription">Displays additional bubbles and dust particles rising across the screen.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableRandomSummerMobile" name="EnableRandomSummerMobile" type="checkbox" is="emby-checkbox" />
<span>Enable Additional Random Bubbles and Dust on Mobile</span>
</label>
<div class="fieldDescription">Displays additional bubbles and dust particles rising across the screen on mobile devices. Warning: High values may affect performance.</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="SummerBubbleCount">Bubbles Count</label>
<input is="emby-input" type="number" id="SummerBubbleCount" name="SummerBubbleCount" />
<div class="fieldDescription">Number of bubbles (if enabled).</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="SummerDustCount">Dust Count</label>
<input is="emby-input" type="number" id="SummerDustCount" name="SummerDustCount" />
<div class="fieldDescription">Number of dust particles (if enabled).</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableDifferentDurationSummer" name="EnableDifferentDurationSummer" type="checkbox" is="emby-checkbox" />
<span>Enable Different Rising Speed</span>
</label>
<div class="fieldDescription">Randomize the rising speed of bubbles.</div>
</div>
</details>
<hr style="max-width: 800px; margin: 1em 0;">
<details>
<summary>Carnival</summary>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableCarnival" name="EnableCarnival" type="checkbox" is="emby-checkbox" />
<span>Enable Carnival Seasonal</span>
</label>
<div class="fieldDescription">Enable the Carnival theme in general (e.g. for automation).</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableRandomCarnival" name="EnableRandomCarnival" type="checkbox" is="emby-checkbox" />
<span>Enable Additional Random Confetti</span>
</label>
<div class="fieldDescription">Displays additional confetti falling and fluttering across the screen.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableRandomCarnivalMobile" name="EnableRandomCarnivalMobile" type="checkbox" is="emby-checkbox" />
<span>Enable Additional Random Confetti on Mobile</span>
</label>
<div class="fieldDescription">Displays additional confetti falling and fluttering across the screen on mobile devices. Warning: High values may affect performance.</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="CarnivalObjectCount">Confetti Count</label>
<input is="emby-input" type="number" id="CarnivalObjectCount" name="CarnivalObjectCount" />
<div class="fieldDescription">Number of additional confetti pieces (if enabled).</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableDifferentDurationCarnival" name="EnableDifferentDurationCarnival" type="checkbox" is="emby-checkbox" />
<span>Enable Different Falling Speed</span>
</label>
<div class="fieldDescription">Randomize the falling speed of confetti.</div>
</div>
</details>
</div> </div>
<div style="background-color: rgba(255, 255, 255, 0.05); border-left: 4px solid #00a4dc; border-radius: 4px; padding: 1em 1.5em; margin: 1.5em 0; display: flex; align-items: center; gap: 1em;"> <div style="background-color: rgba(255, 255, 255, 0.05); border-left: 4px solid #00a4dc; border-radius: 4px; padding: 1em 1.5em; margin: 1.5em 0; display: flex; align-items: center; gap: 1em;">
@@ -615,7 +749,7 @@
} }
.seasonal-rule-content { .seasonal-rule-content {
display: grid; display: grid;
grid-template-columns: 2fr 1.2fr 1.2fr 2fr; grid-template-columns: 2fr 1.3fr 1.3fr 2fr;
gap: 15px; gap: 15px;
align-items: end; align-items: end;
} }
@@ -627,7 +761,13 @@
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: flex-end; align-items: flex-end;
justify-content: space-between;
} }
.date-range-group > .inputContainer,
.date-range-group > .selectContainer {
flex: 1;
}
@media (max-width: 800px) { @media (max-width: 800px) {
.seasonal-rule-content { .seasonal-rule-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -678,87 +818,116 @@
var endMonth = data ? (data.EndMonth !== undefined ? data.EndMonth : (data.endMonth !== undefined ? data.endMonth : 1)) : 1; var endMonth = data ? (data.EndMonth !== undefined ? data.EndMonth : (data.endMonth !== undefined ? data.endMonth : 1)) : 1;
var theme = data ? (data.Theme || data.theme || 'none') : 'none'; var theme = data ? (data.Theme || data.theme || 'none') : 'none';
div.innerHTML = ` var days = [];
<div class="seasonal-rule-header"> for (var i = 1; i <= 31; i++) days.push(i);
<div style="font-weight: bold; font-size: 1.1em;" class="rule-title">${name}</div>
<div class="rule-actions">
<button type="button" is="paper-icon-button-light" class="btn-move-up" title="Move Up"><i class="material-icons">arrow_upward</i></button>
<button type="button" is="paper-icon-button-light" class="btn-move-down" title="Move Down"><i class="material-icons">arrow_downward</i></button>
<button type="button" is="paper-icon-button-light" class="btn-remove" title="Remove"><i class="material-icons">delete</i></button>
</div>
</div>
<div class="seasonal-rule-content">
<div class="inputContainer" style="margin:0;">
<label class="inputLabel">Name</label>
<input is="emby-input" class="rule-name" onchange="this.closest('.seasonal-rule').querySelector('.rule-title').innerText = this.value" />
</div>
<div class="date-range-group">
<div class="inputContainer" style="margin:0;">
<label class="inputLabel">Start Day</label>
<input is="emby-input" type="number" class="rule-start-day" min="1" max="31" />
</div>
<div class="inputContainer" style="margin:0;">
<label class="inputLabel">Month</label>
<input is="emby-input" type="number" class="rule-start-month" min="1" max="12" />
</div>
</div>
<div class="date-range-group">
<div class="inputContainer" style="margin:0;">
<label class="inputLabel">End Day</label>
<input is="emby-input" type="number" class="rule-end-day" min="1" max="31" />
</div>
<div class="inputContainer" style="margin:0;">
<label class="inputLabel">Month</label>
<input is="emby-input" type="number" class="rule-end-month" min="1" max="12" />
</div>
</div>
<div class="selectContainer" style="margin:0;"> var months = [
<label class="selectLabel">Theme</label> { v: 1, n: 'Jan' }, { v: 2, n: 'Feb' }, { v: 3, n: 'Mar' }, { v: 4, n: 'Apr' },
<select is="emby-select" class="rule-theme" style="width: 100%;"> { v: 5, n: 'May' }, { v: 6, n: 'Jun' }, { v: 7, n: 'Jul' }, { v: 8, n: 'Aug' },
<option value="none">None</option> { v: 9, n: 'Sep' }, { v: 10, n: 'Oct' }, { v: 11, n: 'Nov' }, { v: 12, n: 'Dec' }
<option value="snowflakes">Snowflakes</option> ];
<option value="snowfall">Snowfall</option>
<option value="snowstorm">Snowstorm</option> // Build select HTML via string concatenation to avoid Jellyfin's ${} localization processing
<option value="fireworks">Fireworks</option> function mkSelect(val, opts, cls) {
<option value="halloween">Halloween</option> var h = '<select class="emby-select emby-select-withcolor ' + cls + '" style="width: 100%; -webkit-appearance: menulist; appearance: menulist;">';
<option value="hearts">Hearts</option> opts.forEach(function(o) {
<option value="christmas">Christmas</option> var v = o.v || o;
<option value="santa">Santa</option> var n = o.n || o;
<option value="autumn">Autumn</option> h += '<option value="' + v + '" ' + (v == val ? 'selected' : '') + '>' + n + '</option>';
<option value="easter">Easter</option> });
<option value="resurrection">Resurrection</option> h += '</select>';
</select> return h;
</div> }
</div>
`; div.innerHTML =
'<div class="seasonal-rule-header">' +
' <div style="font-weight: bold; font-size: 1.1em;" class="rule-title"></div>' +
' <div class="rule-actions">' +
' <button type="button" is="paper-icon-button-light" class="btn-move-up" title="Move Up"><i class="material-icons">arrow_upward</i></button>' +
' <button type="button" is="paper-icon-button-light" class="btn-move-down" title="Move Down"><i class="material-icons">arrow_downward</i></button>' +
' <button type="button" is="paper-icon-button-light" class="btn-remove" title="Remove"><i class="material-icons">delete</i></button>' +
' </div>' +
'</div>' +
'<div class="seasonal-rule-content">' +
' <div class="inputContainer" style="margin:0;">' +
' <label class="inputLabel">Name</label>' +
' <input is="emby-input" class="rule-name" onchange="SeasonalsConfigPage.updateRuleTitles();" />' +
' </div>' +
' <div class="date-range-group">' +
' <div class="selectContainer" style="margin:0; flex: 1;">' +
' <label class="selectLabel">Start Day</label>' +
mkSelect(startDay, days, 'rule-start-day') +
' </div>' +
' <div class="selectContainer" style="margin:0; flex: 1;">' +
' <label class="selectLabel">Month</label>' +
mkSelect(startMonth, months, 'rule-start-month') +
' </div>' +
' </div>' +
' <div class="date-range-group">' +
' <div class="selectContainer" style="margin:0; flex: 1;">' +
' <label class="selectLabel">End Day</label>' +
mkSelect(endDay, days, 'rule-end-day') +
' </div>' +
' <div class="selectContainer" style="margin:0; flex: 1;">' +
' <label class="selectLabel">Month</label>' +
mkSelect(endMonth, months, 'rule-end-month') +
' </div>' +
' </div>' +
' <div class="selectContainer" style="margin:0;">' +
' <label class="selectLabel">Theme</label>' +
' <select class="emby-select emby-select-withcolor rule-theme" style="width: 100%; -webkit-appearance: menulist; appearance: menulist;">' +
' <option value="none">None</option>' +
' <option value="snowflakes">Snowflakes</option>' +
' <option value="snowfall">Snowfall</option>' +
' <option value="snowstorm">Snowstorm</option>' +
' <option value="fireworks">Fireworks</option>' +
' <option value="halloween">Halloween</option>' +
' <option value="hearts">Hearts</option>' +
' <option value="christmas">Christmas</option>' +
' <option value="santa">Santa</option>' +
' <option value="autumn">Autumn</option>' +
' <option value="easter">Easter</option>' +
' <option value="resurrection">Resurrection</option>' +
' <option value="spring">Spring</option>' +
' <option value="summer">Summer</option>' +
' <option value="carnival">Carnival</option>' +
' </select>' +
' </div>' +
'</div>';
container.appendChild(div); container.appendChild(div);
// Set values programmatically to ensure they are correctly populated // Set values programmatically
div.querySelector('.rule-name').value = name; div.querySelector('.rule-name').value = name;
div.querySelector('.rule-title').innerText = name;
div.querySelector('.rule-start-day').value = startDay;
div.querySelector('.rule-start-month').value = startMonth;
div.querySelector('.rule-end-day').value = endDay;
div.querySelector('.rule-end-month').value = endMonth;
div.querySelector('.rule-theme').value = theme; div.querySelector('.rule-theme').value = theme;
// Bind events // Bind events
div.querySelector('.btn-remove').addEventListener('click', function() { div.querySelector('.btn-remove').addEventListener('click', function() {
div.remove(); div.remove();
SeasonalsConfigPage.updateRuleTitles();
}); });
div.querySelector('.btn-move-up').addEventListener('click', function() { div.querySelector('.btn-move-up').addEventListener('click', function() {
if (div.previousElementSibling) { if (div.previousElementSibling) {
container.insertBefore(div, div.previousElementSibling); container.insertBefore(div, div.previousElementSibling);
SeasonalsConfigPage.updateRuleTitles();
} }
}); });
div.querySelector('.btn-move-down').addEventListener('click', function() { div.querySelector('.btn-move-down').addEventListener('click', function() {
if (div.nextElementSibling) { if (div.nextElementSibling) {
container.insertBefore(div.nextElementSibling, div); container.insertBefore(div.nextElementSibling, div);
SeasonalsConfigPage.updateRuleTitles();
} }
}); });
this.updateRuleTitles();
},
updateRuleTitles: function() {
var rules = document.querySelectorAll('.seasonal-rule');
rules.forEach((rule, index) => {
var name = rule.querySelector('.rule-name').value;
rule.querySelector('.rule-title').innerText = '#' + (index + 1) + ' ' + name;
});
}, },
renderRules: function(rules) { renderRules: function(rules) {
@@ -894,6 +1063,31 @@
document.querySelector('#EnableRandomResurrectionMobile').checked = config.Resurrection.EnableRandomSymbolsMobile; document.querySelector('#EnableRandomResurrectionMobile').checked = config.Resurrection.EnableRandomSymbolsMobile;
document.querySelector('#EnableDifferentDurationResurrection').checked = config.Resurrection.EnableDifferentDuration; document.querySelector('#EnableDifferentDurationResurrection').checked = config.Resurrection.EnableDifferentDuration;
// Spring
document.querySelector('#EnableSpring').checked = config.Spring.EnableSpring;
document.querySelector('#SpringPetalCount').value = config.Spring.PetalCount;
document.querySelector('#SpringPollenCount').value = config.Spring.PollenCount;
document.querySelector('#SpringLadybugCount').value = config.Spring.LadybugCount;
document.querySelector('#SpringSunbeamCount').value = config.Spring.SunbeamCount;
document.querySelector('#EnableRandomSpring').checked = config.Spring.EnableRandomSpring;
document.querySelector('#EnableRandomSpringMobile').checked = config.Spring.EnableRandomSpringMobile;
document.querySelector('#EnableDifferentDurationSpring').checked = config.Spring.EnableDifferentDuration;
// Summer
document.querySelector('#EnableSummer').checked = config.Summer.EnableSummer;
document.querySelector('#SummerBubbleCount').value = config.Summer.BubbleCount;
document.querySelector('#SummerDustCount').value = config.Summer.DustCount;
document.querySelector('#EnableRandomSummer').checked = config.Summer.EnableRandomSummer;
document.querySelector('#EnableRandomSummerMobile').checked = config.Summer.EnableRandomSummerMobile;
document.querySelector('#EnableDifferentDurationSummer').checked = config.Summer.EnableDifferentDuration;
// Carnival
document.querySelector('#EnableCarnival').checked = config.Carnival.EnableCarnival;
document.querySelector('#CarnivalObjectCount').value = config.Carnival.ObjectCount;
document.querySelector('#EnableRandomCarnival').checked = config.Carnival.EnableRandomCarnival;
document.querySelector('#EnableRandomCarnivalMobile').checked = config.Carnival.EnableRandomCarnivalMobile;
document.querySelector('#EnableDifferentDurationCarnival').checked = config.Carnival.EnableDifferentDuration;
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
}); });
}); });
@@ -1001,6 +1195,31 @@
config.Resurrection.EnableRandomSymbolsMobile = document.querySelector('#EnableRandomResurrectionMobile').checked; config.Resurrection.EnableRandomSymbolsMobile = document.querySelector('#EnableRandomResurrectionMobile').checked;
config.Resurrection.EnableDifferentDuration = document.querySelector('#EnableDifferentDurationResurrection').checked; config.Resurrection.EnableDifferentDuration = document.querySelector('#EnableDifferentDurationResurrection').checked;
// Spring
config.Spring.EnableSpring = document.querySelector('#EnableSpring').checked;
config.Spring.PetalCount = parseInt(document.querySelector('#SpringPetalCount').value);
config.Spring.PollenCount = parseInt(document.querySelector('#SpringPollenCount').value);
config.Spring.LadybugCount = parseInt(document.querySelector('#SpringLadybugCount').value);
config.Spring.SunbeamCount = parseInt(document.querySelector('#SpringSunbeamCount').value);
config.Spring.EnableRandomSpring = document.querySelector('#EnableRandomSpring').checked;
config.Spring.EnableRandomSpringMobile = document.querySelector('#EnableRandomSpringMobile').checked;
config.Spring.EnableDifferentDuration = document.querySelector('#EnableDifferentDurationSpring').checked;
// Summer
config.Summer.EnableSummer = document.querySelector('#EnableSummer').checked;
config.Summer.BubbleCount = parseInt(document.querySelector('#SummerBubbleCount').value);
config.Summer.DustCount = parseInt(document.querySelector('#SummerDustCount').value);
config.Summer.EnableRandomSummer = document.querySelector('#EnableRandomSummer').checked;
config.Summer.EnableRandomSummerMobile = document.querySelector('#EnableRandomSummerMobile').checked;
config.Summer.EnableDifferentDuration = document.querySelector('#EnableDifferentDurationSummer').checked;
// Carnival
config.Carnival.EnableCarnival = document.querySelector('#EnableCarnival').checked;
config.Carnival.ObjectCount = parseInt(document.querySelector('#CarnivalObjectCount').value);
config.Carnival.EnableRandomCarnival = document.querySelector('#EnableRandomCarnival').checked;
config.Carnival.EnableRandomCarnivalMobile = document.querySelector('#EnableRandomCarnivalMobile').checked;
config.Carnival.EnableDifferentDuration = document.querySelector('#EnableDifferentDurationCarnival').checked;
ApiClient.updatePluginConfiguration(SeasonalsConfigPage.pluginUniqueId, config).then(function (result) { ApiClient.updatePluginConfiguration(SeasonalsConfigPage.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
}); });

View File

@@ -12,7 +12,7 @@
<!-- <TreatWarningsAsErrors>false</TreatWarningsAsErrors> --> <!-- <TreatWarningsAsErrors>false</TreatWarningsAsErrors> -->
<Title>Jellyfin Seasonals Plugin</Title> <Title>Jellyfin Seasonals Plugin</Title>
<Authors>CodeDevMLH</Authors> <Authors>CodeDevMLH</Authors>
<Version>1.7.0.6</Version> <Version>1.7.1.1</Version>
<RepositoryUrl>https://github.com/CodeDevMLH/Jellyfin-Seasonals</RepositoryUrl> <RepositoryUrl>https://github.com/CodeDevMLH/Jellyfin-Seasonals</RepositoryUrl>
</PropertyGroup> </PropertyGroup>
@@ -26,7 +26,7 @@
<None Remove="Configuration\configPage.html" /> <None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Web\**" /> <None Remove="Web\**" />
<EmbeddedResource Include="Web\**" /> <EmbeddedResource Include="Web\**" Exclude="Web\test-site.html" />
<None Include="..\README.md" /> <None Include="..\README.md" />
<None Include="..\logo.png" CopyToOutputDirectory="Always" /> <None Include="..\logo.png" CopyToOutputDirectory="Always" />

View File

@@ -56,6 +56,18 @@ public class ScriptInjector
} }
var content = File.ReadAllText(indexPath); var content = File.ReadAllText(indexPath);
// MARK: Legacy Tags, remove in future versions
bool modified = false;
// Cleanup legacy tags first to avoid duplicates or conflicts
content = RemoveLegacyTags(content, ref modified);
if (modified)
{
_logger.LogInformation("Removed legacy tags from index.html.");
}
if (!content.Contains(ScriptTag)) if (!content.Contains(ScriptTag))
{ {
var index = content.IndexOf(Marker, StringComparison.OrdinalIgnoreCase); var index = content.IndexOf(Marker, StringComparison.OrdinalIgnoreCase);
@@ -113,6 +125,17 @@ public class ScriptInjector
} else { } else {
_logger.LogInformation("Seasonals script tag not found in index.html. No removal necessary."); _logger.LogInformation("Seasonals script tag not found in index.html. No removal necessary.");
} }
// MARK: Legacy Tags, remove in future versions
// Remove legacy tags
bool modified = false;
content = RemoveLegacyTags(content, ref modified);
if (modified)
{
_logger.LogInformation("Removed legacy tags from index.html.");
}
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
@@ -204,4 +227,21 @@ public class ScriptInjector
_logger.LogWarning(ex, "Error attempting to unregister file transformation. It might not have been registered."); _logger.LogWarning(ex, "Error attempting to unregister file transformation. It might not have been registered.");
} }
} }
// MARK: Legacy Tags, remove in future versions
/// <summary>
/// Removes legacy script tags from the content.
/// </summary>
private string RemoveLegacyTags(string content, ref bool modified)
{
// Legacy tags (used in versions prior to 1.6.3.0 where paths started with / instead of ../)
const string LegacyScriptTag = "<script src=\"/Seasonals/Resources/seasonals.js\" defer></script>";
if (content.Contains(LegacyScriptTag))
{
content = content.Replace(LegacyScriptTag + Environment.NewLine, "").Replace(LegacyScriptTag, "");
modified = true;
}
return content;
}
} }

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -1,4 +1,4 @@
.ressurection-container { .resurrection-container {
display: block; display: block;
position: fixed; position: fixed;
overflow: hidden; overflow: hidden;
@@ -10,20 +10,20 @@
z-index: 10; z-index: 10;
} }
.ressurection-symbol { .resurrection-symbol {
position: fixed; position: fixed;
z-index: 15; z-index: 15;
top: -15%; top: -15%;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
cursor: default; cursor: default;
animation-name: ressurection-fall, ressurection-sway; animation-name: resurrection-fall, resurrection-sway;
animation-timing-function: linear, ease-in-out; animation-timing-function: linear, ease-in-out;
animation-iteration-count: infinite, infinite; animation-iteration-count: infinite, infinite;
will-change: transform, top; will-change: transform, top;
} }
.ressurection-symbol img { .resurrection-symbol img {
z-index: 15; z-index: 15;
height: auto; height: auto;
width: 56px; width: 56px;
@@ -32,12 +32,12 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.ressurection-symbol img { .resurrection-symbol img {
width: 42px; width: 42px;
} }
} }
@keyframes ressurection-fall { @keyframes resurrection-fall {
0% { 0% {
top: -15%; top: -15%;
} }
@@ -47,7 +47,7 @@
} }
} }
@keyframes ressurection-sway { @keyframes resurrection-sway {
0%, 0%,
100% { 100% {
transform: translateX(0); transform: translateX(0);

View File

@@ -68,6 +68,11 @@ const ThemeConfigs = {
js: '../Seasonals/Resources/spring.js', js: '../Seasonals/Resources/spring.js',
containerClass: 'spring-container' containerClass: 'spring-container'
}, },
carnival: {
css: '../Seasonals/Resources/carnival.css',
js: '../Seasonals/Resources/carnival.js',
containerClass: 'carnival-container'
},
none: { none: {
containerClass: 'none' containerClass: 'none'
}, },

View File

@@ -0,0 +1,179 @@
.spring-container {
display: block;
position: fixed;
overflow: hidden;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1000;
}
/* Petals */
.spring-petal {
position: fixed;
top: -20px;
z-index: 1005;
width: 15px;
height: 10px;
background-color: #ffc0cb;
border-radius: 15px 0px 15px 0px;
will-change: transform, top;
animation-name: spring-fall, spring-sway;
animation-timing-function: linear, ease-in-out;
animation-iteration-count: infinite, infinite;
animation-duration: 10s, 3s;
}
.spring-petal.lighter {
background-color: #ffd1dc;
opacity: 0.8;
}
.spring-petal.darker {
background-color: #ffb7c5;
opacity: 0.9;
}
.spring-petal.type2 {
width: 12px;
height: 12px;
border-radius: 10px 0px 10px 5px;
}
/* Pollen */
.spring-pollen {
position: fixed;
z-index: 14;
background-color: #fffacd;
border-radius: 50%;
opacity: 0.6;
box-shadow: 0 0 4px rgba(255, 250, 205, 0.4);
will-change: transform;
animation-name: spring-float;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
/* Sunbeams */
.spring-sunbeam {
position: fixed;
top: -50%;
height: 200%;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 200, 0.08) 50%, rgba(255, 255, 255, 0));
z-index: 5;
transform-origin: top center;
pointer-events: none;
animation-name: spring-beam-pulse;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
/* Grass */
.spring-grass-container {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 80px;
z-index: 1002;
overflow: hidden;
pointer-events: none;
}
.spring-grass {
position: absolute;
bottom: 0;
width: 3px;
border-radius: 100% 0 0 0;
transform-origin: bottom center;
background-color: #4caf50;
will-change: transform;
animation-name: spring-grass-sway;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
/* Ladybugs */
.spring-ladybug {
position: absolute;
width: 6px;
height: 4px;
background-color: #e74c3c; /* Red */
border-radius: 3px 3px 0 0;
z-index: 1003;
will-change: left, transform;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.spring-ladybug.right {
animation-name: spring-bug-crawl-right;
transform: scaleX(1);
}
.spring-ladybug.left {
animation-name: spring-bug-crawl-left;
transform: scaleX(-1);
}
.spring-ladybug::before {
content: '';
position: absolute;
right: -2px;
top: 1px;
width: 2px;
height: 2px;
background-color: #000;
border-radius: 50%;
}
@keyframes spring-fall {
0% { top: -10%; }
100% { top: 100%; }
}
@keyframes spring-sway {
0%, 100% {
transform: translateX(0) rotate(0deg);
}
50% {
transform: translateX(30px) rotate(45deg);
}
}
@keyframes spring-float {
0% { transform: translateX(0) translateY(0); }
25% { transform: translateX(20px) translateY(-10px); }
50% { transform: translateX(40px) translateY(0); }
75% { transform: translateX(20px) translateY(10px); }
100% { transform: translateX(0) translateY(0); }
}
@keyframes spring-beam-pulse {
0% { opacity: 0.3; transform: rotate(45deg) scaleX(1); }
50% { opacity: 0.6; transform: rotate(45deg) scaleX(1.1); }
100% { opacity: 0.3; transform: rotate(45deg) scaleX(1); }
}
@keyframes spring-grass-sway {
0% { transform: rotate(0deg); }
50% { transform: rotate(8deg); }
100% { transform: rotate(0deg); }
}
@keyframes spring-bug-crawl-right {
0% { left: -5%; }
100% { left: 105%; }
}
@keyframes spring-bug-crawl-left {
0% { left: 105%; }
100% { left: -5%; }
}

View File

@@ -0,0 +1,245 @@
const config = window.SeasonalsPluginConfig?.Spring || {};
const spring = config.EnableSpring !== undefined ? config.EnableSpring : true;
const petalCount = config.PetalCount || 25;
const pollenCount = config.PollenCount || 15;
const ladybugCountConfig = config.LadybugCount || 5;
const sunbeamCount = config.SunbeamCount || 5;
const randomSpring = config.EnableRandomSpring !== undefined ? config.EnableRandomSpring : true;
const randomSpringMobile = config.EnableRandomSpringMobile !== undefined ? config.EnableRandomSpringMobile : false;
const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true;
const enablePetals = config.EnableSpringPetals !== undefined ? config.EnableSpringPetals : true;
const enableSunbeams = config.EnableSpringSunbeams !== undefined ? config.EnableSpringSunbeams : true;
let msgPrinted = false;
function toggleSpring() {
const springContainer = document.querySelector('.spring-container');
if (!springContainer) 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');
if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) {
springContainer.style.display = 'none';
if (!msgPrinted) {
console.log('Spring hidden');
msgPrinted = true;
}
} else {
springContainer.style.display = 'block';
if (msgPrinted) {
console.log('Spring visible');
msgPrinted = false;
}
}
}
const observer = new MutationObserver(toggleSpring);
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
function createPetal(container) {
if (!enablePetals) return;
const petal = document.createElement('div');
petal.classList.add('spring-petal');
const type = Math.random() > 0.5 ? 'type1' : 'type2';
petal.classList.add(type);
const color = Math.random() > 0.7 ? 'darker' : 'lighter';
petal.classList.add(color);
const randomLeft = Math.random() * 100;
petal.style.left = `${randomLeft}%`;
const size = Math.random() * 0.5 + 0.5;
petal.style.transform = `scale(${size})`;
const duration = Math.random() * 5 + 8;
const delay = Math.random() * 10;
const swayDuration = Math.random() * 2 + 2;
if (enableDifferentDuration) {
petal.style.animationDuration = `${duration}s, ${swayDuration}s`;
}
petal.style.animationDelay = `${delay}s, ${Math.random() * 3}s`;
container.appendChild(petal);
}
function createPollen(container) {
const pollen = document.createElement('div');
pollen.classList.add('spring-pollen');
const startY = Math.random() * 60 + 20;
pollen.style.top = `${startY}%`;
pollen.style.left = `${Math.random() * 100}%`;
const size = Math.random() * 3 + 1;
pollen.style.width = `${size}px`;
pollen.style.height = `${size}px`;
const duration = Math.random() * 20 + 20;
pollen.style.animationDuration = `${duration}s`;
pollen.style.animationDelay = `-${Math.random() * 20}s`;
container.appendChild(pollen);
}
function createSunbeam(container) {
if (!enableSunbeams) return;
const beam = document.createElement('div');
beam.classList.add('spring-sunbeam');
const left = Math.random() * 100; // Spread across full width
beam.style.left = `${left}%`;
// Thinner beams as requested
const width = Math.random() * 20 + 10; // 10-30px wide
beam.style.width = `${width}px`;
const rotate = Math.random() * 20 - 10 + 45;
beam.style.transform = `rotate(${rotate}deg)`;
const duration = Math.random() * 10 + 10;
beam.style.animationDuration = `${duration}s`;
beam.style.animationDelay = `-${Math.random() * 10}s`;
container.appendChild(beam);
}
function createGrass(container) {
let grassContainer = container.querySelector('.spring-grass-container');
if (!grassContainer) {
grassContainer = document.createElement('div');
grassContainer.className = 'spring-grass-container';
container.appendChild(grassContainer);
}
// More grass: 1 blade every 3px (was 15px)
const bladeCount = window.innerWidth / 3;
for (let i = 0; i < bladeCount; i++) {
const blade = document.createElement('div');
blade.classList.add('spring-grass');
const height = Math.random() * 40 + 20; // 20-60px height
blade.style.height = `${height}px`;
blade.style.left = `${i * 3 + Math.random() * 2}px`;
const duration = Math.random() * 2 + 3;
blade.style.animationDuration = `${duration}s`;
blade.style.animationDelay = `-${Math.random() * 5}s`;
const hue = 100 + Math.random() * 40;
blade.style.backgroundColor = `hsl(${hue}, 60%, 40%)`;
grassContainer.appendChild(blade);
}
// Add Ladybugs
const bugs = ladybugCountConfig;
for (let i = 0; i < bugs; i++) {
createLadybug(grassContainer);
}
}
function createLadybug(container) {
const bug = document.createElement('div');
bug.classList.add('spring-ladybug');
const direction = Math.random() > 0.5 ? 'right' : 'left';
bug.classList.add(direction);
// Position lower (bottom of grass), but ensure visibility
const bottomOffset = direction === 'right' ? Math.random() * 5 + 6 : Math.random() * 5 + 2;
bug.style.bottom = `${bottomOffset}px`;
// Start position depends on direction
if (direction === 'right') {
bug.style.left = '-5%'; // Start off-screen left
} else {
bug.style.left = '105%'; // Start off-screen right
}
const duration = Math.random() * 20 + 20; // Slow crawl
bug.style.animationDuration = `${duration}s`;
bug.style.animationDelay = `-${Math.random() * 20}s`;
container.appendChild(bug);
}
function addRandomSpringObjects() {
const container = document.querySelector('.spring-container');
if (!container) return;
if (enablePetals) {
for (let i = 0; i < petalCount; i++) {
createPetal(container);
}
}
for (let i = 0; i < pollenCount; i++) {
createPollen(container);
}
}
function initSpringObjects() {
let container = document.querySelector('.spring-container');
if (!container) {
container = document.createElement("div");
container.className = "spring-container";
container.setAttribute("aria-hidden", "true");
document.body.appendChild(container);
}
createGrass(container);
if (enablePetals) {
for (let i = 0; i < 15; i++) {
const petal = document.createElement('div');
petal.classList.add('spring-petal');
const type = Math.random() > 0.5 ? 'type1' : 'type2';
petal.classList.add(type);
const color = Math.random() > 0.7 ? 'darker' : 'lighter';
petal.classList.add(color);
const randomLeft = Math.random() * 100;
petal.style.left = `${randomLeft}%`;
const size = Math.random() * 0.5 + 0.5;
petal.style.transform = `scale(${size})`;
const duration = Math.random() * 5 + 8;
const swayDuration = Math.random() * 2 + 2;
if (enableDifferentDuration) {
petal.style.animationDuration = `${duration}s, ${swayDuration}s`;
}
petal.style.animationDelay = `-${Math.random() * 10}s, -${Math.random() * 3}s`;
container.appendChild(petal);
}
}
if (enableSunbeams) {
// Initial sunbeams
for (let i = 0; i < sunbeamCount; i++) {
createSunbeam(container);
}
}
}
function initializeSpring() {
if (!spring) return;
initSpringObjects();
toggleSpring();
const screenWidth = window.innerWidth;
if (randomSpring && (screenWidth > 768 || randomSpringMobile)) {
addRandomSpringObjects();
}
}
initializeSpring();

View File

@@ -0,0 +1,80 @@
.summer-container {
display: block;
position: fixed;
overflow: hidden;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.summer-bubble {
position: fixed;
bottom: -50px;
z-index: 15;
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.05));
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
will-change: transform, bottom;
animation-name: summer-rise, summer-wobble;
animation-timing-function: linear, ease-in-out;
animation-iteration-count: infinite, infinite;
animation-duration: 10s, 3s;
}
.summer-dust {
position: fixed;
bottom: -10px;
z-index: 12;
background-color: rgba(255, 223, 186, 0.6);
border-radius: 50%;
box-shadow: 0 0 5px rgba(255, 223, 186, 0.4);
will-change: transform, bottom;
animation-name: summer-rise, summer-drift;
animation-timing-function: linear, ease-in-out;
animation-iteration-count: infinite, infinite;
animation-duration: 20s, 5s;
}
@keyframes summer-rise {
0% {
bottom: -10%;
}
100% {
bottom: 110%;
}
}
@keyframes summer-wobble {
0% {
transform: translateX(0);
}
33% {
transform: translateX(15px);
}
66% {
transform: translateX(-15px);
}
100% {
transform: translateX(0);
}
}
@keyframes summer-drift {
0% {
transform: translateX(0) translateY(0);
}
50% {
transform: translateX(30px) translateY(-20px);
}
100% {
transform: translateX(0) translateY(0);
}
}

View File

@@ -0,0 +1,143 @@
const config = window.SeasonalsPluginConfig?.Summer || {};
const summer = config.EnableSummer !== undefined ? config.EnableSummer : true; // enable/disable summer
const bubbleCount = config.BubbleCount || 20;
const dustCount = config.DustCount || 50;
const randomSummer = config.EnableRandomSummer !== undefined ? config.EnableRandomSummer : true; // enable random objects
const randomSummerMobile = config.EnableRandomSummerMobile !== undefined ? config.EnableRandomSummerMobile : false; // enable random objects on mobile
const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; // enable different animation duration
let msgPrinted = false;
function toggleSummer() {
const summerContainer = document.querySelector('.summer-container');
if (!summerContainer) 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');
if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) {
summerContainer.style.display = 'none';
if (!msgPrinted) {
console.log('Summer hidden');
msgPrinted = true;
}
} else {
summerContainer.style.display = 'block';
if (msgPrinted) {
console.log('Summer visible');
msgPrinted = false;
}
}
}
const observer = new MutationObserver(toggleSummer);
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
function createBubble(container, isDust = false) {
const bubble = document.createElement('div');
if (isDust) {
bubble.classList.add('summer-dust');
} else {
bubble.classList.add('summer-bubble');
}
// Random horizontal position
const randomLeft = Math.random() * 100;
bubble.style.left = `${randomLeft}%`;
// Random size
if (!isDust) {
const size = Math.random() * 20 + 10; // 10-30px bubbles
bubble.style.width = `${size}px`;
bubble.style.height = `${size}px`;
} else {
const size = Math.random() * 3 + 1; // 1-4px dust
bubble.style.width = `${size}px`;
bubble.style.height = `${size}px`;
}
// Animation properties
const duration = isDust ? (Math.random() * 20 + 10) : (Math.random() * 10 + 5); // Dust is slower
const delay = Math.random() * 10;
if (enableDifferentDuration) {
bubble.style.animationDuration = `${duration}s`;
}
bubble.style.animationDelay = `${delay}s`;
container.appendChild(bubble);
}
function addRandomSummerObjects() {
const container = document.querySelector('.summer-container');
if (!container) return;
// Add bubbles
for (let i = 0; i < bubbleCount; i++) {
createBubble(container, false);
}
// Add some dust particles (more of them, they are subtle)
for (let i = 0; i < dustCount; i++) {
createBubble(container, true);
}
}
function initSummerObjects() {
let container = document.querySelector('.summer-container');
if (!container) {
container = document.createElement("div");
container.className = "summer-container";
container.setAttribute("aria-hidden", "true");
document.body.appendChild(container);
}
// Initial bubbles/dust
for (let i = 0; i < 10; i++) {
const bubble = document.createElement('div');
const isDust = Math.random() > 0.5;
if (isDust) {
bubble.classList.add('summer-dust');
} else {
bubble.classList.add('summer-bubble');
}
const randomLeft = Math.random() * 100;
bubble.style.left = `${randomLeft}%`;
if (!isDust) {
const size = Math.random() * 20 + 10;
bubble.style.width = `${size}px`;
bubble.style.height = `${size}px`;
} else {
const size = Math.random() * 3 + 1;
bubble.style.width = `${size}px`;
bubble.style.height = `${size}px`;
}
const duration = isDust ? (Math.random() * 20 + 10) : (Math.random() * 10 + 5);
if (enableDifferentDuration) {
bubble.style.animationDuration = `${duration}s`;
}
bubble.style.animationDelay = `-${Math.random() * 10}s`;
container.appendChild(bubble);
}
}
function initializeSummer() {
if (!summer) return;
initSummerObjects();
toggleSummer();
const screenWidth = window.innerWidth;
if (randomSummer && (screenWidth > 768 || randomSummerMobile)) {
addRandomSummerObjects();
}
}
initializeSummer();

View File

@@ -1,79 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Seasonals Test Display</title>
<link rel="stylesheet" href="snowfall.css">
<style>
body {
background-color: black;
color: white;
margin: 0;
font-family: sans-serif;
}
/* Mock Jellyfin Header */
.skinHeader {
background-color: #101010;
height: 60px;
display: flex;
align-items: center;
padding: 0 1em;
border-bottom: 1px solid #333;
}
.headerRight {
margin-left: auto;
display: flex;
align-items: center;
}
.paper-icon-button-light {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 10px;
}
.material-icons {
font-family: 'Material Icons';
/* Placeholder if not loaded */
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
}
</style>
<!-- Load Material Icons for the snowflake -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<!-- Mock Header Structure -->
<div class="skinHeader">
<div class="skinHeader-content">
<span>Jellyfin Mock Header</span>
</div>
<div class="headerRight">
<button class="paper-icon-button-light">
<span class="material-icons">person</span>
</button>
</div>
</div>
<div class="seasonals-container"></div>
<!-- Load the main script -->
<script src="seasonals.js"></script>
</body>
</html>

View File

@@ -0,0 +1,506 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Seasonals Theme Tester</title>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: #101010;
color: #e0e0e0;
font-family: 'Inter', sans-serif;
}
/* ── Mock Jellyfin Header ── */
.skinHeader {
background-color: #181818;
height: 60px;
display: flex;
align-items: center;
padding: 0 1.5em;
border-bottom: 1px solid #282828;
position: relative;
z-index: 100;
}
.skinHeader-content { font-size: 1.1em; font-weight: 500; }
.headerRight {
margin-left: auto;
display: flex;
align-items: center;
}
.paper-icon-button-light {
background: none;
border: none;
color: #ccc;
cursor: pointer;
padding: 10px;
border-radius: 50%;
transition: background 0.2s;
}
.paper-icon-button-light:hover { background: rgba(255,255,255,0.1); }
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
}
/* ── Control Panel ── */
.control-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(12px);
border-top: 1px solid #333;
padding: 1em 1.5em;
z-index: 200;
display: flex;
align-items: center;
gap: 1em;
flex-wrap: wrap;
}
.control-panel label {
font-size: 0.85em;
color: #aaa;
font-weight: 500;
white-space: nowrap;
}
.control-panel select,
.control-panel input[type="text"] {
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #444;
border-radius: 6px;
padding: 0.5em 0.75em;
font-size: 0.9em;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.control-panel select:focus,
.control-panel input[type="text"]:focus {
border-color: #00a4dc;
}
.control-panel select { min-width: 160px; }
.control-panel input[type="text"] { width: 160px; }
.control-panel button {
background: #00a4dc;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5em 1.2em;
font-size: 0.9em;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
}
.control-panel button:hover { background: #008bbd; }
.control-panel button.btn-secondary {
background: #333;
color: #ccc;
}
.control-panel button.btn-secondary:hover { background: #444; }
.custom-fields {
display: none;
align-items: center;
gap: 0.75em;
}
.custom-fields.visible { display: flex; }
/* ── Mock Content ── */
.mock-content {
padding: 2em 1.5em 6em;
}
.mock-content h2 {
font-size: 1.4em;
margin-bottom: 1em;
color: #fff;
}
.mock-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1em;
}
.mock-card {
background: #1a1a1a;
border-radius: 8px;
aspect-ratio: 2/3;
position: relative;
overflow: hidden;
}
.mock-card::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
}
.info-bar {
position: fixed;
bottom: 65px;
left: 0;
right: 0;
background: rgba(0, 164, 220, 0.15);
border-top: 1px solid rgba(0, 164, 220, 0.3);
padding: 0.5em 1.5em;
z-index: 199;
font-size: 0.8em;
color: #aaa;
text-align: center;
}
.info-bar a {
color: #00a4dc;
text-decoration: none;
}
</style>
</head>
<body>
<!-- Mock Header -->
<div class="skinHeader">
<div class="skinHeader-content">
<span>Jellyfin</span>
</div>
<div class="headerRight">
<button class="paper-icon-button-light" title="Search">
<span class="material-icons">search</span>
</button>
<button class="paper-icon-button-light" title="User">
<span class="material-icons">person</span>
</button>
</div>
</div>
<!-- Seasonals Container (themes inject here) -->
<div class="seasonals-container"></div>
<!-- Mock Library Content -->
<div class="mock-content">
<h2>My Media</h2>
<div class="mock-grid">
<div class="mock-card"></div>
<div class="mock-card"></div>
<div class="mock-card"></div>
<div class="mock-card"></div>
<div class="mock-card"></div>
<div class="mock-card"></div>
<div class="mock-card"></div>
<div class="mock-card"></div>
<div class="mock-card"></div>
<div class="mock-card"></div>
</div>
</div>
<!-- Info Bar -->
<div class="info-bar">
📖 See <a href="../../CONTRIBUTING.md">CONTRIBUTING.md</a> for how to create your own seasonal theme
</div>
<!-- Control Panel -->
<div class="control-panel">
<label for="theme-select">Theme:</label>
<select id="theme-select">
<option value="" selected disabled>— Select a theme —</option>
<option value="snowfall">Snowfall</option>
<option value="snowflakes">Snowflakes</option>
<option value="snowstorm">Snowstorm</option>
<option value="fireworks">Fireworks</option>
<option value="halloween">Halloween</option>
<option value="hearts">Hearts</option>
<option value="christmas">Christmas</option>
<option value="santa">Santa</option>
<option value="autumn">Autumn</option>
<option value="easter">Easter</option>
<option value="resurrection">Resurrection</option>
<option value="spring">Spring</option>
<option value="summer">Summer (Bubbles)</option>
<option value="carnival">Carnival (Confetti)</option>
<option value="custom">⚙ Custom (Local Files)</option>
</select>
<div id="custom-fields" class="custom-fields">
<p>Javascript:</p>
<input type="text" id="custom-js" placeholder="mytheme.js">
<p>CSS:</p>
<input type="text" id="custom-css" placeholder="mytheme.css">
</div>
<button id="btn-load" onclick="loadTheme()">Load Theme</button>
<button id="btn-clear" class="btn-secondary" onclick="clearTheme()">Clear & Reload</button>
</div>
<script>
// ── Path Rewriter ──────────────────────────────────────────
// Theme JS files reference images using the production path:
// ../Seasonals/Resources/santa_images/gift1.png
// When testing locally, the images live next to this HTML file:
// ./santa_images/gift1.png
// This observer intercepts all new <img> elements and rewrites
// their src attribute so image-based themes (santa, halloween,
// autumn, easter, resurrection) work out of the box.
const PRODUCTION_PREFIX = '../Seasonals/Resources/';
const LOCAL_PREFIX = './';
function rewritePath(src) {
if (!src) return src;
// Handle both full URLs and relative paths
const idx = src.indexOf('Seasonals/Resources/');
if (idx !== -1) {
return LOCAL_PREFIX + src.substring(idx + 'Seasonals/Resources/'.length);
}
return src;
}
function rewriteElement(el) {
if (el.tagName === 'IMG' && el.src && el.src.includes('Seasonals/Resources/')) {
const newSrc = rewritePath(el.src);
console.log(`[Path Rewriter] ${el.src}${newSrc}`);
el.src = newSrc;
}
}
// Watch for dynamically added images and rewrite their paths
const pathRewriter = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue; // skip non-elements
rewriteElement(node);
// Also check children (e.g. a div with img inside)
if (node.querySelectorAll) {
node.querySelectorAll('img').forEach(rewriteElement);
}
}
// Also catch src attribute changes on existing elements
if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
rewriteElement(mutation.target);
}
}
});
pathRewriter.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
// ── Built-in theme map (local file paths for testing) ──
const themes = {
snowfall: { css: 'snowfall.css', js: 'snowfall.js', container: 'snowfall-container' },
snowflakes: { css: 'snowflakes.css', js: 'snowflakes.js', container: 'snowflakes' },
snowstorm: { css: 'snowstorm.css', js: 'snowstorm.js', container: 'snowstorm-container' },
fireworks: { css: 'fireworks.css', js: 'fireworks.js', container: 'fireworks' },
halloween: { css: 'halloween.css', js: 'halloween.js', container: 'halloween-container' },
hearts: { css: 'hearts.css', js: 'hearts.js', container: 'hearts-container' },
christmas: { css: 'christmas.css', js: 'christmas.js', container: 'christmas-container' },
santa: { css: 'santa.css', js: 'santa.js', container: 'santa-container' },
autumn: { css: 'autumn.css', js: 'autumn.js', container: 'autumn-container' },
easter: { css: 'easter.css', js: 'easter.js', container: 'easter-container' },
resurrection: { css: 'resurrection.css', js: 'resurrection.js', container: 'resurrection-container' },
spring: { css: 'spring.css', js: 'spring.js', container: 'spring-container' },
summer: { css: 'summer.css', js: 'summer.js', container: 'summer-container' },
carnival: { css: 'carnival.css', js: 'carnival.js', container: 'carnival-container' },
};
const select = document.getElementById('theme-select');
const customFields = document.getElementById('custom-fields');
select.addEventListener('change', () => {
customFields.classList.toggle('visible', select.value === 'custom');
});
function clearTheme() {
// Remove injected CSS
document.querySelectorAll('link[data-seasonal]').forEach(el => el.remove());
// Remove injected JS
document.querySelectorAll('script[data-seasonal]').forEach(el => el.remove());
// Reset the seasonals container
const container = document.querySelector('.seasonals-container');
container.className = 'seasonals-container';
container.innerHTML = '';
// Remove any theme-created containers on body
const knownContainers = [
'.snowfall-container', '.snowflakes', '.snowstorm-container',
'.fireworks', '.halloween-container', '.hearts-container',
'.christmas-container', '.santa-container', '.autumn-container',
'.christmas-container', '.santa-container', '.autumn-container',
'.easter-container', '.resurrection-container', '.spring-container',
'.summer-container', '.carnival-container'
];
knownContainers.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
if (!el.classList.contains('seasonals-container')) el.remove();
});
});
// Remove any canvas elements left over
document.querySelectorAll('#snowfallCanvas, #snowstormCanvas').forEach(el => el.remove());
// Remove rabbit element
document.querySelectorAll('#rabbit, .hopping-rabbit').forEach(el => el.remove());
// Remove santa element
document.querySelectorAll('.santa, .present').forEach(el => el.remove());
console.log('[Test Site] Theme cleared.');
}
// Track active animation frames and observers for cleanup
let activeAnimationFrames = [];
let activeBlobUrls = [];
// Patch requestAnimationFrame and MutationObserver to track them
const origRAF = window.requestAnimationFrame;
const origCAF = window.cancelAnimationFrame;
let trackingEnabled = false;
window.requestAnimationFrame = function(cb) {
const id = origRAF.call(window, cb);
if (trackingEnabled) activeAnimationFrames.push(id);
return id;
};
window.cancelAnimationFrame = function(id) {
origCAF.call(window, id);
activeAnimationFrames = activeAnimationFrames.filter(f => f !== id);
};
// Track MutationObservers created by themes
let activeObservers = [];
const OrigMO = window.MutationObserver;
window.MutationObserver = class extends OrigMO {
constructor(cb) {
super(cb);
if (trackingEnabled) activeObservers.push(this);
}
};
// Track intervals created by themes
let activeIntervals = [];
const origSetInterval = window.setInterval;
const origClearInterval = window.clearInterval;
window.setInterval = function(...args) {
const id = origSetInterval.apply(window, args);
if (trackingEnabled) activeIntervals.push(id);
return id;
};
window.clearInterval = function(id) {
origClearInterval.call(window, id);
activeIntervals = activeIntervals.filter(i => i !== id);
};
function loadTheme() {
clearTheme();
// Cancel all tracked animation frames
activeAnimationFrames.forEach(id => origCAF.call(window, id));
activeAnimationFrames = [];
// Disconnect all tracked MutationObservers
activeObservers.forEach(obs => obs.disconnect());
activeObservers = [];
// Clear all tracked intervals
activeIntervals.forEach(id => origClearInterval.call(window, id));
activeIntervals = [];
// Revoke old blob URLs
activeBlobUrls.forEach(url => URL.revokeObjectURL(url));
activeBlobUrls = [];
const value = select.value;
if (!value || value === '') return;
let cssFile, jsFile, containerClass;
if (value === 'custom') {
cssFile = document.getElementById('custom-css').value.trim();
jsFile = document.getElementById('custom-js').value.trim();
containerClass = cssFile ? cssFile.replace('.css', '-container') : 'custom-container';
} else {
const theme = themes[value];
cssFile = theme.css;
jsFile = theme.js;
containerClass = theme.container;
}
// Update the seasonals-container class
const container = document.querySelector('.seasonals-container');
container.className = `seasonals-container ${containerClass}`;
// Inject CSS
if (cssFile) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = cssFile;
link.setAttribute('data-seasonal', 'true');
link.onerror = () => console.error(`[Test Site] Failed to load CSS: ${cssFile}`);
document.head.appendChild(link);
}
// Inject JS wrapped in IIFE to avoid const redeclaration errors
if (jsFile) {
setTimeout(async () => {
try {
const response = await fetch(jsFile);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const code = await response.text();
// Wrap in IIFE so each theme has its own scope
const wrappedCode = `(function() {\n${code}\n})();`;
const blob = new Blob([wrappedCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
activeBlobUrls.push(blobUrl);
trackingEnabled = true;
const script = document.createElement('script');
script.src = blobUrl;
script.setAttribute('data-seasonal', 'true');
script.onerror = () => console.error(`[Test Site] Failed to execute JS: ${jsFile}`);
document.body.appendChild(script);
console.log(`[Test Site] Loaded theme: ${value} (${jsFile}) [IIFE-wrapped]`);
} catch (err) {
console.error(`[Test Site] Failed to load JS: ${jsFile}`, err);
}
}, 150);
}
}
</script>
</body>
</html>

View File

@@ -107,7 +107,7 @@ Click on the following themes to expand them and see the theme in action:
## Ideas for additional seasonals ## Ideas for additional seasonals
If you have any (specific) ideas for additional seasonals, feel free to open an issue or create a pull request. If you have any (specific) ideas for additional seasonals, feel free to open an issue or create a pull request. See the [contributing guidelines](CONTRIBUTING.md) for more details.
## Installation ## Installation
@@ -243,4 +243,5 @@ volumes:
## Contributing ## Contributing
Feel free to contribute to this project by creating pull requests or reporting issues. Feel free to contribute to this project by creating pull requests or reporting issues.
For detailed contribution guidelines, please refer to the [CONTRIBUTING.md](CONTRIBUTING.md) file.

View File

@@ -9,12 +9,20 @@
"imageUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/raw/branch/main/logo.png", "imageUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/raw/branch/main/logo.png",
"versions": [ "versions": [
{ {
"version": "1.7.0.7", "version": "1.7.1.1",
"changelog": "- feat: add summer, spring and carnival themes",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/releases/download/v1.7.1.1/Jellyfin.Plugin.Seasonals.zip",
"checksum": "58a418e0070aab6f47b136fc305e2fc1",
"timestamp": "2026-02-19T18:00:57Z"
},
{
"version": "1.7.0.15",
"changelog": "- feat: add customizable auto seasonal list via config page\n- feat: add new season theme 'resurrection' by Bioflash257", "changelog": "- feat: add customizable auto seasonal list via config page\n- feat: add new season theme 'resurrection' by Bioflash257",
"targetAbi": "10.11.0.0", "targetAbi": "10.11.0.0",
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/releases/download/v1.7.0.7/Jellyfin.Plugin.Seasonals.zip", "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/releases/download/v1.7.0.15/Jellyfin.Plugin.Seasonals.zip",
"checksum": "dfc48cd9c1c7d57fc6ecd14d5d657066", "checksum": "d1fc094710efe45ea8cc885bc6a826c4",
"timestamp": "2026-02-16T17:49:48Z" "timestamp": "2026-02-17T13:11:21Z"
}, },
{ {
"version": "1.6.3.0", "version": "1.6.3.0",