Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35d92862aa | ||
|
|
693bb35aac | ||
|
|
1ddaab325e | ||
|
|
81facbdb00 | ||
|
|
34a58ac4bd | ||
|
|
2d8444701d | ||
|
|
66f5353659 | ||
|
|
b58264998a | ||
|
|
76c0bc5b3b | ||
|
|
1428db3e1e | ||
|
|
1f5f436e44 | ||
|
|
46f5c3648d | ||
|
|
555e2ab8be | ||
|
|
26eadfc0aa | ||
|
|
142f538939 | ||
|
|
b64e80fd60 | ||
|
|
fbf5fc7edf | ||
|
|
8defba4623 | ||
|
|
7f968ee050 | ||
|
|
dec5bbe39e | ||
|
|
63f3211cc4 | ||
|
|
4270235c78 | ||
|
|
76d8a67914 | ||
|
|
1a3caf5da6 | ||
|
|
3b3ef77e61 | ||
|
|
ba580b1b52 | ||
|
|
0a6284c716 | ||
|
|
f83e863664 | ||
|
|
747e8ed6bc | ||
|
|
30845442b2 | ||
|
|
bb83201736 | ||
|
|
457ae404ba | ||
|
|
b6d679f6ef | ||
|
|
3b88a1809d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
343
CONTRIBUTING.md
Normal 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 **2–3 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` 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` 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`
|
||||||
|
- [ ] 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! 🎉
|
||||||
@@ -88,17 +88,20 @@
|
|||||||
|
|
||||||
<!-- 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;">
|
||||||
<div style="background-color: rgba(255, 255, 255, 0.05); border-left: 4px solid #00a4dc; border-radius: 4px; padding: 1em 1.5em; margin-bottom: 1.5em;">
|
<h2>Auto Selection Rules</h2>
|
||||||
<h3>Auto Selection Rules</h3>
|
|
||||||
<p>Define rules to automatically select a season based on the date. Rules are evaluated from top to bottom. The first matching rule wins.</p>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div id="seasonalRulesList">
|
<div id="seasonalRulesList">
|
||||||
<!-- 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();"
|
||||||
<i class="material-icons button-icon button-icon-left">add</i>
|
style="margin-top: 1em; display: inline-flex; align-items: center; gap: 0.4em;">
|
||||||
|
<i class="material-icons" style="font-size: 24px;">add</i>
|
||||||
<span>Add Rule</span>
|
<span>Add Rule</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,8 +616,7 @@
|
|||||||
}
|
}
|
||||||
.seasonal-rule-content {
|
.seasonal-rule-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* grid-template-columns: 2fr 1fr 1fr 2fr; */
|
grid-template-columns: 2fr 1.3fr 1.3fr 2fr;
|
||||||
grid-template-columns: 2fr 1.2fr 1.2fr 1.6fr;
|
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
@@ -626,7 +628,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;
|
||||||
@@ -639,10 +647,10 @@
|
|||||||
border-bottom: 2px solid #00a4dc !important;
|
border-bottom: 2px solid #00a4dc !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disabled options in selects
|
/* Disabled options in selects */
|
||||||
select option:disabled {
|
select option:disabled {
|
||||||
color: #a3a3a3 !important;
|
color: #a3a3a3 !important;
|
||||||
} */
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
function showSeasonalsTab(tabId, btn) {
|
function showSeasonalsTab(tabId, btn) {
|
||||||
@@ -677,86 +685,113 @@
|
|||||||
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>
|
});
|
||||||
</select>
|
h += '</select>';
|
||||||
</div>
|
return h;
|
||||||
</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>' +
|
||||||
|
' </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) {
|
||||||
|
|||||||
@@ -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.0.15</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" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
498
Jellyfin.Plugin.Seasonals/Web/test-site.html
Normal file
498
Jellyfin.Plugin.Seasonals/Web/test-site.html
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
<!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="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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
'.easter-container', '.resurrection-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>
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,12 @@
|
|||||||
"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.5",
|
"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.5/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": "dc018048e121b88469b8d8340970b2a6",
|
"checksum": "d1fc094710efe45ea8cc885bc6a826c4",
|
||||||
"timestamp": "2026-02-16T17:16:02Z"
|
"timestamp": "2026-02-17T13:11:21Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "1.6.3.0",
|
"version": "1.6.3.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user