Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c10583601 | ||
|
|
20dcf08bda | ||
|
|
e4b3a132b1 | ||
|
|
63ec6d5e52 | ||
|
|
ec89f2d48d | ||
|
|
61b21de566 | ||
|
|
590f2c3606 | ||
|
|
fdadc00a0c | ||
|
|
2ab88fd5ac | ||
|
|
9a41c0a2ce | ||
|
|
816f58cf02 | ||
|
|
5be9a60eed | ||
|
|
133808105e | ||
|
|
c631aca44f | ||
|
|
241450d132 | ||
|
|
d50d71bde1 | ||
|
|
262dd98519 | ||
|
|
b45ec73a67 | ||
|
|
4e8a37540f | ||
|
|
cde5201991 | ||
|
|
b2420b8eb4 | ||
|
|
dacec7d03c | ||
|
|
65f8261fb7 | ||
|
|
78872e7f96 | ||
|
|
45c9a199c2 | ||
|
|
1df6fb37b1 | ||
|
|
82a1e8a178 | ||
|
|
22bf887d10 | ||
|
|
07600766cf | ||
|
|
56298487f4 | ||
|
|
89fc1c38f0 | ||
|
|
4c168a5ec2 | ||
|
|
92d9e1a9ad | ||
|
|
007e55a612 | ||
|
|
20da9899e4 | ||
|
|
9b9cad1caa | ||
|
|
e8e3424cc9 | ||
|
|
0eeed99508 | ||
|
|
a0f261f597 | ||
|
|
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 | ||
|
|
4614ce4a7a | ||
|
|
57840bb149 | ||
|
|
dd90a4630a | ||
|
|
b5d5e5706e | ||
|
|
a4b5cf5b6b | ||
|
|
353bda10df | ||
|
|
0e1b91d93c | ||
|
|
9363008d07 | ||
|
|
faec7d8941 | ||
|
|
7cc70854c4 | ||
|
|
9432f7aa86 | ||
|
|
4f7243bc74 | ||
|
|
ee724fedc8 | ||
|
|
a1dbd4eb12 | ||
|
|
236d8d9e70 | ||
|
|
6d55ae7524 | ||
|
|
99a0613893 | ||
|
|
61952a0af7 | ||
|
|
eca6ba96fb | ||
|
|
c2f0f01689 | ||
|
|
30d17baff4 | ||
|
|
96bb1a3744 | ||
|
|
772a0dae40 | ||
|
|
40c4454397 | ||
|
|
e5915e715a | ||
|
|
c171fc15f5 | ||
|
|
a749b1f98e | ||
|
|
6ccf6201b4 | ||
|
|
a69c741a39 | ||
|
|
d54b4f9b07 | ||
|
|
2cd427b6e9 | ||
|
|
55c1f8b191 | ||
|
|
fc3d6efd1c | ||
|
|
5ba5940e5f | ||
|
|
621b7da344 | ||
|
|
268ce5e307 | ||
|
|
412cc2d981 | ||
|
|
949df24bdb | ||
|
|
b987969200 | ||
|
|
3306bb703d | ||
|
|
6587a4e3d0 | ||
|
|
f794b71f44 | ||
|
|
34363c502a | ||
|
|
add2f7a551 | ||
|
|
1d7e9e27ec | ||
|
|
6459653328 | ||
|
|
9d738e6061 | ||
|
|
8f5a3650e6 | ||
|
|
229f9fe5ab | ||
|
|
0686129590 | ||
|
|
cb0392eb0d | ||
|
|
ed13e05b82 | ||
|
|
310fb4d496 | ||
|
|
78d25106db | ||
|
|
a328171a8a | ||
|
|
361559cbec | ||
|
|
e08bf66a53 | ||
|
|
d6ef81138d | ||
|
|
35f21e680a | ||
|
|
705fbaed9d | ||
|
|
9e52198ef7 | ||
|
|
b1943dfe17 | ||
|
|
c55e900c0f | ||
|
|
503e9addee | ||
|
|
d630fdd217 | ||
|
|
7e4a7c2a6e | ||
|
|
1716a771f3 | ||
|
|
36347cc4b0 | ||
|
|
7f94164e55 | ||
|
|
cbab7de546 | ||
|
|
d0de5cd021 | ||
|
|
16628e9902 | ||
|
|
72bfe0a14a | ||
|
|
6498ec4216 | ||
|
|
0d350fc76b | ||
|
|
2c6e4ce610 | ||
|
|
0c552774dc | ||
|
|
9ab605bb74 | ||
|
|
3d6cba0fe4 | ||
|
|
32e5e2b690 | ||
|
|
c967c1e308 | ||
|
|
ae28d5219b | ||
|
|
e4228f889e | ||
|
|
6d721c755e | ||
|
|
6948953778 | ||
|
|
8a50cef330 | ||
|
|
a0bf5370bd | ||
|
|
c5800b431d | ||
|
|
9a4056651d | ||
|
|
87382db78e | ||
|
|
5d9afa762f | ||
|
|
2f88587dab | ||
|
|
360a959b69 | ||
|
|
36fba545cf | ||
|
|
7faa2cc766 | ||
|
|
aa832e93aa | ||
|
|
86bbeb583d | ||
|
|
7a642b34b8 | ||
|
|
926b30b8ce | ||
|
|
5b672cef42 | ||
|
|
ceccbf4ded | ||
|
|
9cba2a0755 | ||
|
|
af036a9aa4 | ||
|
|
cfefd2d2d3 | ||
|
|
8e7299508b | ||
|
|
fc7aa36f41 | ||
|
|
fc9896048f | ||
|
|
572c4d9ace | ||
|
|
2572e085f6 | ||
|
|
8297f989fd | ||
|
|
636aaa2a4a | ||
|
|
5e70621e93 |
@@ -51,7 +51,31 @@ jobs:
|
||||
echo "$CHANGELOG" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Check if Release Already Exists
|
||||
id: check_release
|
||||
shell: bash
|
||||
run: |
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
VERSION="${{ env.VERSION }}"
|
||||
TAG="v$VERSION"
|
||||
SERVER_URL="https://git.mahom03-spacecloud.de"
|
||||
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$SERVER_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases/tags/$TAG")
|
||||
|
||||
if [ "$HTTP_STATUS" -eq 200 ]; then
|
||||
echo "Release $TAG already exists. Skipping release-related steps."
|
||||
echo "release_exists=true" >> $GITHUB_OUTPUT
|
||||
elif [ "$HTTP_STATUS" -eq 404 ]; then
|
||||
echo "No existing release for $TAG. Continuing."
|
||||
echo "release_exists=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Unexpected response when checking release: $HTTP_STATUS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build and Zip
|
||||
if: steps.check_release.outputs.release_exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
# Inject version from manifest into the build
|
||||
@@ -71,6 +95,7 @@ jobs:
|
||||
echo "ZIP_PATH=bin/Publish/Jellyfin.Plugin.Seasonals.zip" >> $GITHUB_ENV
|
||||
|
||||
- name: Update manifest.json
|
||||
if: steps.check_release.outputs.release_exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
@@ -90,12 +115,14 @@ jobs:
|
||||
manifest.json > manifest.json.tmp && mv manifest.json.tmp manifest.json
|
||||
|
||||
- name: Commit manifest.json
|
||||
if: steps.check_release.outputs.release_exists == 'false'
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "Update manifest.json for release v${{ env.VERSION }} [skip ci]"
|
||||
file_pattern: manifest.json
|
||||
|
||||
- name: Create Release
|
||||
if: steps.check_release.outputs.release_exists == 'false'
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
server_url: "https://git.mahom03-spacecloud.de"
|
||||
@@ -109,6 +136,7 @@ jobs:
|
||||
|
||||
# Update Message in Remote Repository
|
||||
- name: Checkout Central Manifest Repo
|
||||
if: steps.check_release.outputs.release_exists == 'false'
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ github.repository_owner }}/jellyfin-plugin-manifest
|
||||
@@ -116,6 +144,7 @@ jobs:
|
||||
token: ${{ secrets.JELLYFIN_PLUGIN_MANIFEST_UPDATER_PAT }}
|
||||
|
||||
- name: Update Central Manifest
|
||||
if: steps.check_release.outputs.release_exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
cd central-manifest
|
||||
@@ -171,6 +200,7 @@ jobs:
|
||||
manifest.json > manifest.json.tmp && mv manifest.json.tmp manifest.json
|
||||
|
||||
- name: Commit and Push Central Manifest
|
||||
if: steps.check_release.outputs.release_exists == 'false'
|
||||
run: |
|
||||
cd central-manifest
|
||||
git config user.name "CodeDevMLH"
|
||||
|
||||
3
.gitignore
vendored
@@ -4,6 +4,5 @@ obj/
|
||||
.idea/
|
||||
artifacts
|
||||
|
||||
test-site.html
|
||||
test-site-new.html
|
||||
test-site-old.html
|
||||
RELEASE_GUIDE.md
|
||||
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`](./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
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ public class SeasonalsController : ControllerBase
|
||||
if (path.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) return "image/png";
|
||||
if (path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase)) return "image/jpeg";
|
||||
if (path.EndsWith(".gif", StringComparison.OrdinalIgnoreCase)) return "image/gif";
|
||||
if (path.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) return "image/svg+xml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,17 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
Christmas = new ChristmasOptions();
|
||||
Santa = new SantaOptions();
|
||||
Easter = new EasterOptions();
|
||||
Resurrection = new ResurrectionOptions();
|
||||
Spring = new SpringOptions();
|
||||
Summer = new SummerOptions();
|
||||
CherryBlossom = new CherryBlossomOptions();
|
||||
Carnival = new CarnivalOptions();
|
||||
PiDay = new PiDayOptions();
|
||||
Eurovision = new EurovisionOptions();
|
||||
Storm = new StormOptions();
|
||||
Pride = new PrideOptions();
|
||||
EarthDay = new EarthDayOptions();
|
||||
Rain = new RainOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -49,6 +60,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public bool EnableClientSideToggle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the seasonal rules configuration as JSON.
|
||||
/// </summary>
|
||||
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\"},{\"Name\":\"Cherry Blossom\",\"StartDay\":1,\"StartMonth\":4,\"EndDay\":30,\"EndMonth\":4,\"Theme\":\"cherryblossom\"}]";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Seasonals options.
|
||||
/// </summary>
|
||||
@@ -62,6 +78,17 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
public ChristmasOptions Christmas { get; set; }
|
||||
public SantaOptions Santa { get; set; }
|
||||
public EasterOptions Easter { 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 PiDayOptions PiDay { get; set; }
|
||||
public EurovisionOptions Eurovision { get; set; }
|
||||
public StormOptions Storm { get; set; }
|
||||
public PrideOptions Pride { get; set; }
|
||||
public EarthDayOptions EarthDay { get; set; }
|
||||
public RainOptions Rain { get; set; }
|
||||
}
|
||||
|
||||
public class AutumnOptions
|
||||
@@ -166,3 +193,108 @@ public class EasterOptions
|
||||
public int MinBunnyRestTime { get; set; } = 2000;
|
||||
public int MaxBunnyRestTime { get; set; } = 5000;
|
||||
}
|
||||
|
||||
public class ResurrectionOptions
|
||||
{
|
||||
public int SymbolCount { get; set; } = 12;
|
||||
public bool EnableResurrection { get; set; } = true;
|
||||
public bool EnableRandomSymbols { get; set; } = true;
|
||||
public bool EnableRandomSymbolsMobile { get; set; } = false;
|
||||
public bool EnableDifferentDuration { get; set; } = true;
|
||||
}
|
||||
|
||||
public class SpringOptions
|
||||
{
|
||||
public int PollenCount { get; set; } = 30;
|
||||
public int SunbeamCount { get; set; } = 5;
|
||||
public int BirdCount { get; set; } = 4;
|
||||
public int ButterflyCount { get; set; } = 4;
|
||||
public int BeeCount { get; set; } = 2;
|
||||
public int LadybugCount { get; set; } = 2;
|
||||
public bool EnableSpring { get; set; } = true;
|
||||
public bool EnableSpringSunbeams { 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 bool EnableCarnivalSway { 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;
|
||||
}
|
||||
|
||||
public class PiDayOptions
|
||||
{
|
||||
public int SymbolCount { get; set; } = 50;
|
||||
public bool EnablePiDay { get; set; } = true;
|
||||
public bool EnableRandomPiDay { get; set; } = true;
|
||||
public bool EnableRandomPiDayMobile { get; set; } = false;
|
||||
public bool EnableDifferentDuration { get; set; } = true;
|
||||
}
|
||||
|
||||
public class EurovisionOptions
|
||||
{
|
||||
public int SymbolCount { get; set; } = 25;
|
||||
public bool EnableEurovision { get; set; } = true;
|
||||
public bool EnableRandomEurovision { get; set; } = true;
|
||||
public bool EnableRandomEurovisionMobile { get; set; } = false;
|
||||
public bool EnableDifferentDuration { get; set; } = true;
|
||||
public bool EnableColorfulNotes { get; set; } = true;
|
||||
public string EurovisionColors { get; set; } = "#ff0026ff,#17a6ffff,#32d432ff,#FFD700,#f0821bff,#f826f8ff";
|
||||
public int EurovisionGlowSize { get; set; } = 8;
|
||||
}
|
||||
|
||||
public class StormOptions
|
||||
{
|
||||
public int RaindropCount { get; set; } = 300;
|
||||
public int RaindropCountMobile { get; set; } = 150;
|
||||
public bool EnableStorm { get; set; } = true;
|
||||
public bool EnableLightning { get; set; } = true;
|
||||
public double RainSpeed { get; set; } = 1;
|
||||
}
|
||||
|
||||
public class PrideOptions
|
||||
{
|
||||
public bool EnablePride { get; set; } = true;
|
||||
public int HeartCount { get; set; } = 20;
|
||||
public int HeartSize { get; set; } = 2;
|
||||
public bool ColorHeader { get; set; } = true;
|
||||
}
|
||||
|
||||
public class EarthDayOptions
|
||||
{
|
||||
public bool EnableEarthDay { get; set; } = true;
|
||||
public int VineCount { get; set; } = 4;
|
||||
}
|
||||
|
||||
public class RainOptions
|
||||
{
|
||||
public bool EnableRain { get; set; } = true;
|
||||
public int RaindropCount { get; set; } = 300;
|
||||
public int RaindropCountMobile { get; set; } = 150;
|
||||
public double RainSpeed { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- <TreatWarningsAsErrors>false</TreatWarningsAsErrors> -->
|
||||
<Title>Jellyfin Seasonals Plugin</Title>
|
||||
<Authors>CodeDevMLH</Authors>
|
||||
<Version>1.6.2.0</Version>
|
||||
<Version>1.7.2.0</Version>
|
||||
<RepositoryUrl>https://github.com/CodeDevMLH/Jellyfin-Seasonals</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
<None Remove="Web\**" />
|
||||
<EmbeddedResource Include="Web\**" />
|
||||
<EmbeddedResource Include="Web\**" Exclude="Web\test-site.html" />
|
||||
|
||||
<None Include="..\README.md" />
|
||||
<None Include="..\logo.png" CopyToOutputDirectory="Always" />
|
||||
|
||||
@@ -18,7 +18,7 @@ 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 ScriptTag = "<script src=\"../Seasonals/Resources/seasonals.js\" defer></script>";
|
||||
public const string Marker = "</body>";
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +56,18 @@ public class ScriptInjector
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
var index = content.IndexOf(Marker, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -113,6 +125,17 @@ public class ScriptInjector
|
||||
} else {
|
||||
_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)
|
||||
{
|
||||
@@ -204,4 +227,21 @@ public class ScriptInjector
|
||||
_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;
|
||||
}
|
||||
}
|
||||
1343
Jellyfin.Plugin.Seasonals/Web/assets/logo_SW.svg
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/assets/logo_SW_192x192.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/assets/logo_SW_96x96.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -8,6 +8,7 @@
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.leaf {
|
||||
@@ -44,7 +45,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
top: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +55,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
top: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,23 +48,23 @@ observer.observe(document.body, {
|
||||
|
||||
|
||||
const images = [
|
||||
"/Seasonals/Resources/autumn_images/acorn1.png",
|
||||
"/Seasonals/Resources/autumn_images/acorn2.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf1.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf2.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf3.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf4.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf5.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf6.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf7.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf8.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf9.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf10.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf11.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf12.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf13.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf14.png",
|
||||
"/Seasonals/Resources/autumn_images/leaf15.png",
|
||||
"../Seasonals/Resources/autumn_images/acorn1.png",
|
||||
"../Seasonals/Resources/autumn_images/acorn2.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf1.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf2.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf3.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf4.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf5.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf6.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf7.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf8.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf9.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf10.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf11.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf12.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf13.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf14.png",
|
||||
"../Seasonals/Resources/autumn_images/leaf15.png",
|
||||
];
|
||||
|
||||
function addRandomLeaves(count) {
|
||||
|
||||
86
Jellyfin.Plugin.Seasonals/Web/carnival.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.carnival-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
perspective: 600px;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.carnival-wrapper {
|
||||
position: fixed;
|
||||
z-index: 15;
|
||||
top: -20px;
|
||||
will-change: transform;
|
||||
animation-name: carnival-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: 1;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.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: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.carnival-confetti.circle {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.carnival-confetti.square {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.carnival-confetti.triangle {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
|
||||
}
|
||||
|
||||
@keyframes carnival-fall {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(120vh);
|
||||
}
|
||||
}
|
||||
|
||||
@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(var(--rx, 1), var(--ry, 1), var(--rz, 0), 0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate3d(var(--rx, 1), var(--ry, 1), var(--rz, 0), var(--rot-dir, 360deg));
|
||||
}
|
||||
}
|
||||
197
Jellyfin.Plugin.Seasonals/Web/carnival.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const config = window.SeasonalsPluginConfig?.Carnival || {};
|
||||
|
||||
const carnival = config.EnableCarnival !== undefined ? config.EnableCarnival : true; // Enable/disable carnival
|
||||
const randomCarnival = config.EnableRandomCarnival !== undefined ? config.EnableRandomCarnival : true; // Enable random carnival objects
|
||||
const randomCarnivalMobile = config.EnableRandomCarnivalMobile !== undefined ? config.EnableRandomCarnivalMobile : false; // Enable random carnival objects on mobile
|
||||
const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; // Randomize falling and flutter speeds
|
||||
const enableSway = config.EnableCarnivalSway !== undefined ? config.EnableCarnivalSway : true; // Enable side-to-side sway animation
|
||||
const carnivalCount = config.ObjectCount || 120; // Number of confetti pieces to spawn
|
||||
|
||||
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
|
||||
// MARK: CONFETTI SIZE (RECTANGLES)
|
||||
if (!confetti.classList.contains('circle') && !confetti.classList.contains('square') && !confetti.classList.contains('triangle')) {
|
||||
const width = Math.random() * 3 + 4; // 4-7px
|
||||
const height = Math.random() * 5 + 8; // 8-13px
|
||||
confetti.style.width = `${width}px`;
|
||||
confetti.style.height = `${height}px`;
|
||||
} else if (confetti.classList.contains('circle') || confetti.classList.contains('square')) {
|
||||
// MARK: CONFETTI SIZE (CIRCLES/SQUARES)
|
||||
const size = Math.random() * 5 + 5; // 5-10px
|
||||
confetti.style.width = `${size}px`;
|
||||
confetti.style.height = `${size}px`;
|
||||
}
|
||||
|
||||
// Animation settings
|
||||
// MARK: CONFETTI FALLING SPEED (in seconds)
|
||||
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)
|
||||
// MARK: SWAY DISTANCE RANGE (in px)
|
||||
const swayAmount = Math.random() * 70 + 30; // 30-100px
|
||||
const direction = Math.random() > 0.5 ? 1 : -1;
|
||||
swayWrapper.style.setProperty('--sway-amount', `${swayAmount * direction}px`);
|
||||
}
|
||||
|
||||
// Flutter speed and random 3D rotation axis
|
||||
// MARK: CONFETTI FLUTTER ROTATION SPEED
|
||||
confetti.style.animationDuration = `${Math.random() * 2 + 1}s`;
|
||||
confetti.style.setProperty('--rx', Math.random().toFixed(2));
|
||||
confetti.style.setProperty('--ry', Math.random().toFixed(2));
|
||||
confetti.style.setProperty('--rz', (Math.random() * 0.5).toFixed(2));
|
||||
|
||||
// Random direction for 3D rotation
|
||||
const rotDir = Math.random() > 0.5 ? 1 : -1;
|
||||
confetti.style.setProperty('--rot-dir', `${rotDir * 360}deg`);
|
||||
|
||||
if (enableSway) {
|
||||
swayWrapper.appendChild(confetti);
|
||||
wrapper.appendChild(swayWrapper);
|
||||
} else {
|
||||
wrapper.appendChild(confetti);
|
||||
}
|
||||
|
||||
// Respawn confetti when it hits the bottom
|
||||
wrapper.addEventListener('animationend', (e) => {
|
||||
if (e.animationName === 'carnival-fall') {
|
||||
wrapper.remove();
|
||||
createConfettiPiece(container, false); // respawn without initial huge delay
|
||||
}
|
||||
});
|
||||
|
||||
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 < 60; 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();
|
||||
59
Jellyfin.Plugin.Seasonals/Web/cherryblossom.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.cherryblossom-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
/* Petals */
|
||||
.cherryblossom-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: cherryblossom-fall, cherryblossom-sway;
|
||||
animation-timing-function: linear, ease-in-out;
|
||||
animation-iteration-count: infinite, infinite;
|
||||
animation-duration: 10s, 3s;
|
||||
}
|
||||
|
||||
.cherryblossom-petal.lighter {
|
||||
background-color: #ffd1dc;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cherryblossom-petal.darker {
|
||||
background-color: #ffb7c5;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cherryblossom-petal.type2 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 10px 0px 10px 5px;
|
||||
}
|
||||
|
||||
@keyframes cherryblossom-fall {
|
||||
0% { top: -10%; }
|
||||
100% { top: 110%; }
|
||||
}
|
||||
|
||||
@keyframes cherryblossom-sway {
|
||||
0%, 100% {
|
||||
transform: translateX(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(30px) rotate(45deg);
|
||||
}
|
||||
}
|
||||
101
Jellyfin.Plugin.Seasonals/Web/cherryblossom.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const config = window.SeasonalsPluginConfig?.CherryBlossom || {};
|
||||
|
||||
const cherryBlossom = config.EnableCherryBlossom !== undefined ? config.EnableCherryBlossom : true;
|
||||
const petalCount = config.PetalCount || 25;
|
||||
const randomCherryBlossom = config.EnableRandomCherryBlossom !== undefined ? config.EnableRandomCherryBlossom : true;
|
||||
const randomCherryBlossomMobile = config.EnableRandomCherryBlossomMobile !== undefined ? config.EnableRandomCherryBlossomMobile : false;
|
||||
const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
function toggleCherryBlossom() {
|
||||
const container = document.querySelector('.cherryblossom-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('CherryBlossom hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('CherryBlossom visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleCherryBlossom);
|
||||
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
|
||||
|
||||
function createPetal(container) {
|
||||
const petal = document.createElement('div');
|
||||
petal.classList.add('cherryblossom-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 addRandomObjects() {
|
||||
const container = document.querySelector('.cherryblossom-container');
|
||||
if (!container) return;
|
||||
|
||||
for (let i = 0; i < petalCount; i++) {
|
||||
createPetal(container);
|
||||
}
|
||||
}
|
||||
|
||||
function initObjects() {
|
||||
let container = document.querySelector('.cherryblossom-container');
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.className = "cherryblossom-container";
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// Initial batch
|
||||
for (let i = 0; i < 15; i++) {
|
||||
createPetal(container);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeCherryBlossom() {
|
||||
if (!cherryBlossom) return;
|
||||
initObjects();
|
||||
toggleCherryBlossom();
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
if (randomCherryBlossom && (screenWidth > 768 || randomCherryBlossomMobile)) {
|
||||
addRandomObjects();
|
||||
}
|
||||
}
|
||||
|
||||
initializeCherryBlossom();
|
||||
@@ -8,6 +8,7 @@
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.christmas {
|
||||
@@ -37,7 +38,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
top: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
top: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
Jellyfin.Plugin.Seasonals/Web/earthday.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.earthday-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 15vh;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earthday-meadow {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: bottom;
|
||||
animation: grow-meadow 3s cubic-bezier(0.1, 0.8, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes grow-meadow {
|
||||
0% { transform: translateY(100%); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 0.95; }
|
||||
}
|
||||
|
||||
.earthday-sway {
|
||||
transform-origin: bottom center;
|
||||
animation: sway-grass 4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes sway-grass {
|
||||
0% { transform: skewX(-2deg); }
|
||||
100% { transform: skewX(2deg); }
|
||||
}
|
||||
126
Jellyfin.Plugin.Seasonals/Web/earthday.js
Normal file
@@ -0,0 +1,126 @@
|
||||
// 1. Read Configuration
|
||||
const config = window.SeasonalsPluginConfig?.EarthDay || {};
|
||||
|
||||
const enabled = config.EnableEarthDay !== undefined ? config.EnableEarthDay : true;
|
||||
const vineCount = config.VineCount || 4;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
// 2. Toggle Function
|
||||
function toggleEarthDay() {
|
||||
const container = document.querySelector('.earthday-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('EarthDay hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('EarthDay visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. MutationObserver
|
||||
const observer = new MutationObserver(toggleEarthDay);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
// 4. Element Creation
|
||||
function createElements() {
|
||||
const container = document.querySelector('.earthday-container') || document.createElement('div');
|
||||
|
||||
if (!document.querySelector('.earthday-container')) {
|
||||
container.className = 'earthday-container';
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const w = window.innerWidth;
|
||||
const hSVG = Math.floor(window.innerHeight * 0.15) || 100; // 15vh roughly
|
||||
let paths = '';
|
||||
|
||||
// Generate Grass
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const x = Math.random() * w;
|
||||
const h = hSVG * 0.2 + Math.random() * (hSVG * 0.8);
|
||||
const cY = hSVG - h;
|
||||
const bend = x + (Math.random() * 40 - 20);
|
||||
const color = Math.random() > 0.5 ? '#2E8B57' : '#3CB371';
|
||||
const width = 1 + Math.random() * 2;
|
||||
paths += `<path d="M ${x} ${hSVG} Q ${bend} ${cY+hSVG*0.2} ${bend} ${cY}" stroke="${color}" stroke-width="${width}" fill="none"/>`;
|
||||
}
|
||||
|
||||
// Generate Flowers
|
||||
const colors = ['#FF69B4', '#FFD700', '#87CEFA', '#FF4500', '#BA55D3', '#FFA500', '#FF1493'];
|
||||
const flowerCount = Math.max(10, vineCount * 15);
|
||||
for (let i = 0; i < flowerCount; i++) {
|
||||
const x = 10 + Math.random() * (w - 20);
|
||||
const y = hSVG * 0.1 + Math.random() * (hSVG * 0.5);
|
||||
const col = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
paths += `<path d="M ${x} ${hSVG} Q ${x - 5 + Math.random() * 10} ${y+15} ${x} ${y}" stroke="#006400" stroke-width="1.5" fill="none"/>`;
|
||||
|
||||
const r = 2 + Math.random() * 1.5;
|
||||
paths += `<circle cx="${x-r}" cy="${y-r}" r="${r}" fill="${col}"/>`;
|
||||
paths += `<circle cx="${x+r}" cy="${y-r}" r="${r}" fill="${col}"/>`;
|
||||
paths += `<circle cx="${x-r}" cy="${y+r}" r="${r}" fill="${col}"/>`;
|
||||
paths += `<circle cx="${x+r}" cy="${y+r}" r="${r}" fill="${col}"/>`;
|
||||
paths += `<circle cx="${x}" cy="${y}" r="${r*0.7}" fill="#FFF8DC"/>`;
|
||||
}
|
||||
|
||||
const svgContent = `
|
||||
<svg class="earthday-meadow" viewBox="0 0 ${w} ${hSVG}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="earthday-sway">
|
||||
${paths}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
container.innerHTML = svgContent;
|
||||
}
|
||||
|
||||
// 5. Responsive Resize
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
const handleResize = debounce(() => {
|
||||
const container = document.querySelector('.earthday-container');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
createElements();
|
||||
}
|
||||
}, 250);
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// 6. Initialization
|
||||
function initializeEarthDay() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
toggleEarthDay();
|
||||
}
|
||||
|
||||
initializeEarthDay();
|
||||
@@ -8,6 +8,7 @@
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.hopping-rabbit {
|
||||
@@ -58,7 +59,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
top: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +83,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
top: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,20 +61,20 @@ observer.observe(document.body, {
|
||||
|
||||
|
||||
const images = [
|
||||
"/Seasonals/Resources/easter_images/egg_1.png",
|
||||
"/Seasonals/Resources/easter_images/egg_2.png",
|
||||
"/Seasonals/Resources/easter_images/egg_3.png",
|
||||
"/Seasonals/Resources/easter_images/egg_4.png",
|
||||
"/Seasonals/Resources/easter_images/egg_5.png",
|
||||
"/Seasonals/Resources/easter_images/egg_6.png",
|
||||
"/Seasonals/Resources/easter_images/egg_7.png",
|
||||
"/Seasonals/Resources/easter_images/egg_8.png",
|
||||
"/Seasonals/Resources/easter_images/egg_9.png",
|
||||
"/Seasonals/Resources/easter_images/egg_10.png",
|
||||
"/Seasonals/Resources/easter_images/egg_11.png",
|
||||
"/Seasonals/Resources/easter_images/egg_12.png",
|
||||
"../Seasonals/Resources/easter_images/egg_1.png",
|
||||
"../Seasonals/Resources/easter_images/egg_2.png",
|
||||
"../Seasonals/Resources/easter_images/egg_3.png",
|
||||
"../Seasonals/Resources/easter_images/egg_4.png",
|
||||
"../Seasonals/Resources/easter_images/egg_5.png",
|
||||
"../Seasonals/Resources/easter_images/egg_6.png",
|
||||
"../Seasonals/Resources/easter_images/egg_7.png",
|
||||
"../Seasonals/Resources/easter_images/egg_8.png",
|
||||
"../Seasonals/Resources/easter_images/egg_9.png",
|
||||
"../Seasonals/Resources/easter_images/egg_10.png",
|
||||
"../Seasonals/Resources/easter_images/egg_11.png",
|
||||
"../Seasonals/Resources/easter_images/egg_12.png",
|
||||
];
|
||||
const rabbit = "/Seasonals/Resources/easter_images/easter-bunny.png";
|
||||
const rabbit = "../Seasonals/Resources/easter_images/easter-bunny.png";
|
||||
|
||||
function addRandomEaster(count) {
|
||||
const easterContainer = document.querySelector('.easter-container'); // get the leave container
|
||||
|
||||
43
Jellyfin.Plugin.Seasonals/Web/eurovision.css
Normal file
@@ -0,0 +1,43 @@
|
||||
.eurovision-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.music-note-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
/* initial top will be set via JS */
|
||||
opacity: 0;
|
||||
animation: move-right linear infinite;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.music-note {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
|
||||
animation: sway ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Horizontal scroll from left to right */
|
||||
@keyframes move-right {
|
||||
0% { transform: translateX(-10vw); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateX(110vw); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Sine-wave style vertical bouncing for the note itself */
|
||||
@keyframes sway {
|
||||
0% { transform: translateY(-30px); }
|
||||
100% { transform: translateY(30px); }
|
||||
}
|
||||
105
Jellyfin.Plugin.Seasonals/Web/eurovision.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// 1. Read Configuration
|
||||
const config = window.SeasonalsPluginConfig?.Eurovision || {};
|
||||
|
||||
const enabled = config.EnableEurovision !== undefined ? config.EnableEurovision : true;
|
||||
const elementCount = config.SymbolCount || 25;
|
||||
const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true;
|
||||
const enableColorfulNotes = config.EnableColorfulNotes !== undefined ? config.EnableColorfulNotes : true;
|
||||
const eurovisionColorsStr = config.EurovisionColors || '#ff0026ff, #17a6ffff, #32d432ff, #FFD700, #f0821bff, #f826f8ff';
|
||||
const glowSize = config.EurovisionGlowSize !== undefined ? config.EurovisionGlowSize : 2;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
// 2. Toggle Function
|
||||
function toggleEurovision() {
|
||||
const container = document.querySelector('.eurovision-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('Eurovision hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Eurovision visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. MutationObserver
|
||||
const observer = new MutationObserver(toggleEurovision);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
// 4. Element Creation
|
||||
function createElements() {
|
||||
const container = document.querySelector('.eurovision-container') || document.createElement('div');
|
||||
|
||||
if (!document.querySelector('.eurovision-container')) {
|
||||
container.className = 'eurovision-container';
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const notesSymbols = ['♪', '♫', '♬', '♭', '♮', '♯', '𝄞', '𝄢'];
|
||||
const pColors = eurovisionColorsStr.split(',').map(s => s.trim()).filter(s => s);
|
||||
|
||||
for (let i = 0; i < elementCount; i++) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'music-note-wrapper';
|
||||
|
||||
const note = document.createElement('span');
|
||||
note.className = 'music-note';
|
||||
note.textContent = notesSymbols[Math.floor(Math.random() * notesSymbols.length)];
|
||||
wrapper.appendChild(note);
|
||||
|
||||
wrapper.style.top = `${Math.random() * 90}vh`;
|
||||
|
||||
const minMoveDur = 10;
|
||||
const maxMoveDur = 25;
|
||||
const moveDur = enableDifferentDuration
|
||||
? minMoveDur + Math.random() * (maxMoveDur - minMoveDur)
|
||||
: (minMoveDur + maxMoveDur) / 2;
|
||||
wrapper.style.animationDuration = `${moveDur}s`;
|
||||
wrapper.style.animationDelay = `${Math.random() * 15}s`;
|
||||
|
||||
const minSwayDur = 1;
|
||||
const maxSwayDur = 3;
|
||||
const swayDur = minSwayDur + Math.random() * (maxSwayDur - minSwayDur);
|
||||
note.style.animationDuration = `${swayDur}s`;
|
||||
note.style.animationDelay = `${Math.random() * 2}s`;
|
||||
|
||||
note.style.fontSize = `${Math.random() * 1.5 + 1.5}rem`;
|
||||
|
||||
if (enableColorfulNotes && pColors.length > 0) {
|
||||
note.style.color = pColors[Math.floor(Math.random() * pColors.length)];
|
||||
note.style.textShadow = `0 0 ${glowSize}px ${note.style.color}`;
|
||||
} else {
|
||||
note.style.color = `rgba(255, 255, 255, 0.9)`;
|
||||
note.style.textShadow = `0 0 ${glowSize}px rgba(255, 255, 255, 0.6)`;
|
||||
}
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Initialization
|
||||
function initializeEurovision() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
toggleEurovision();
|
||||
}
|
||||
|
||||
initializeEurovision();
|
||||
@@ -7,6 +7,7 @@
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.rocket-trail {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.halloween {
|
||||
@@ -34,11 +35,11 @@
|
||||
|
||||
@-webkit-keyframes halloween-fall {
|
||||
0% {
|
||||
bottom: -10%
|
||||
bottom: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 100%
|
||||
bottom: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +59,11 @@
|
||||
|
||||
@keyframes halloween-fall {
|
||||
0% {
|
||||
bottom: -10%
|
||||
bottom: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 100%
|
||||
bottom: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,9 +46,9 @@ observer.observe(document.body, {
|
||||
|
||||
|
||||
const images = [
|
||||
"/Seasonals/Resources/halloween_images/ghost_20x20.png",
|
||||
"/Seasonals/Resources/halloween_images/bat_20x20.png",
|
||||
"/Seasonals/Resources/halloween_images/pumpkin_20x20.png",
|
||||
"../Seasonals/Resources/halloween_images/ghost_20x20.png",
|
||||
"../Seasonals/Resources/halloween_images/bat_20x20.png",
|
||||
"../Seasonals/Resources/halloween_images/pumpkin_20x20.png",
|
||||
];
|
||||
|
||||
function addRandomSymbols(count) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.heart {
|
||||
@@ -32,11 +33,11 @@
|
||||
|
||||
@-webkit-keyframes heart-fall {
|
||||
0% {
|
||||
bottom: -10%
|
||||
bottom: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 100%
|
||||
bottom: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +57,11 @@
|
||||
|
||||
@keyframes heart-fall {
|
||||
0% {
|
||||
bottom: -10%
|
||||
bottom: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 100%
|
||||
bottom: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
Jellyfin.Plugin.Seasonals/Web/piday.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.piday-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
165
Jellyfin.Plugin.Seasonals/Web/piday.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// 1. Read Configuration
|
||||
const config = window.SeasonalsPluginConfig?.PiDay || {};
|
||||
|
||||
const enabled = config.EnablePiDay !== undefined ? config.EnablePiDay : true;
|
||||
const maxTrails = config.SymbolCount || 25; // Directly mapped, smaller default
|
||||
|
||||
let msgPrinted = false;
|
||||
let isHidden = false;
|
||||
|
||||
// 2. Toggle Function
|
||||
function togglePiDay() {
|
||||
const container = document.querySelector('.piday-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) {
|
||||
if (!isHidden) {
|
||||
container.style.display = 'none';
|
||||
isHidden = true;
|
||||
if (!msgPrinted) {
|
||||
console.log('PiDay hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isHidden) {
|
||||
container.style.display = 'block';
|
||||
isHidden = false;
|
||||
if (msgPrinted) {
|
||||
console.log('PiDay visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. MutationObserver
|
||||
const observer = new MutationObserver(togglePiDay);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
// 4. Element Creation
|
||||
function createElements() {
|
||||
const container = document.querySelector('.piday-container') || document.createElement('div');
|
||||
|
||||
if (!document.querySelector('.piday-container')) {
|
||||
container.className = 'piday-container';
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
canvas.style.display = 'block';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
// const chars = '0123456789πABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
const chars = '0123456789'.split('');
|
||||
const fontSize = 18;
|
||||
|
||||
class Trail {
|
||||
constructor() {
|
||||
this.reset();
|
||||
this.y = Math.random() * -100; // Allow initial staggered start
|
||||
}
|
||||
reset() {
|
||||
const cols = Math.floor(canvas.width / fontSize);
|
||||
this.x = Math.floor(Math.random() * cols);
|
||||
this.y = -Math.round(Math.random() * 20);
|
||||
this.speed = 0.5 + Math.random() * 0.5;
|
||||
this.len = 10 + Math.floor(Math.random() * 20);
|
||||
this.chars = [];
|
||||
for(let i=0; i<this.len; i++) {
|
||||
this.chars.push(chars[Math.floor(Math.random() * chars.length)]);
|
||||
}
|
||||
}
|
||||
update() {
|
||||
const oldY = Math.floor(this.y);
|
||||
this.y += this.speed;
|
||||
const newY = Math.floor(this.y);
|
||||
|
||||
// If crossed a full vertical unit, push a new character and pop the old one to preserve screen positions
|
||||
if (newY > oldY) {
|
||||
this.chars.unshift(chars[Math.floor(Math.random() * chars.length)]);
|
||||
this.chars.pop();
|
||||
}
|
||||
|
||||
// Randomly mutate some characters (heads mutate faster)
|
||||
for (let i = 0; i < this.len; i++) {
|
||||
const chance = i < 3 ? 0.90 : 0.98;
|
||||
if (Math.random() > chance) {
|
||||
this.chars[i] = chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
}
|
||||
if (this.y - this.len > Math.ceil(canvas.height / fontSize)) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
draw(ctx) {
|
||||
const headY = Math.floor(this.y);
|
||||
for (let i = 0; i < this.len; i++) {
|
||||
const charY = headY - i;
|
||||
if (charY < 0 || charY * fontSize > canvas.height + fontSize) continue;
|
||||
|
||||
const ratio = i / this.len;
|
||||
const alpha = 1 - ratio;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = '#0F0';
|
||||
} else if (i === 1) {
|
||||
ctx.fillStyle = `rgba(150, 255, 150, ${alpha})`;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.shadowColor = '#0F0';
|
||||
} else {
|
||||
ctx.fillStyle = `rgba(0, 255, 0, ${alpha * 0.8})`;
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.fillText(this.chars[i], this.x * fontSize + fontSize/2, charY * fontSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trails = [];
|
||||
for(let i=0; i<maxTrails; i++) trails.push(new Trail());
|
||||
|
||||
function loop() {
|
||||
if (isHidden) return; // Pause drawing when hidden
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = 'bold ' + fontSize + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
for (const t of trails) {
|
||||
t.update();
|
||||
t.draw(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
if (window.pidayInterval) clearInterval(window.pidayInterval);
|
||||
window.pidayInterval = setInterval(loop, 50);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Initialization
|
||||
function initializePiDay() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
togglePiDay();
|
||||
}
|
||||
|
||||
initializePiDay();
|
||||
32
Jellyfin.Plugin.Seasonals/Web/pride.css
Normal file
@@ -0,0 +1,32 @@
|
||||
.pride-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
|
||||
.pride-heart {
|
||||
position: absolute;
|
||||
bottom: -50px;
|
||||
animation: pride-rise ease-in infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
||||
@keyframes pride-rise {
|
||||
0% { transform: translateY(0) scale(0.8); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateY(-115vh) scale(1.2); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Coloring the Jellyfin Header */
|
||||
.skinHeader.pride-header {
|
||||
background: linear-gradient(90deg, #E40303, #FF8C00, #FFED00, #008026, #24408E, #732982) !important;
|
||||
}
|
||||
88
Jellyfin.Plugin.Seasonals/Web/pride.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// 1. Read Configuration
|
||||
const config = window.SeasonalsPluginConfig?.Pride || {};
|
||||
|
||||
const enabled = config.EnablePride !== undefined ? config.EnablePride : true;
|
||||
const elementCount = config.HeartCount || 20;
|
||||
const heartSize = config.HeartSize || 1.5;
|
||||
const colorHeader = config.ColorHeader !== undefined ? config.ColorHeader : true;
|
||||
|
||||
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 togglePride() {
|
||||
const container = document.querySelector('.pride-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('Pride hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Pride visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. MutationObserver
|
||||
// Watches the DOM for changes so the effect can auto-hide/show.
|
||||
const observer = new MutationObserver(togglePride);
|
||||
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('.pride-container') || document.createElement('div');
|
||||
|
||||
if (!document.querySelector('.pride-container')) {
|
||||
container.className = 'pride-container';
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
if (colorHeader) {
|
||||
const header = document.querySelector('.skinHeader');
|
||||
if (header) {
|
||||
header.classList.add('pride-header');
|
||||
}
|
||||
}
|
||||
|
||||
const heartEmojis = ['❤️', '🧡', '💛', '💚', '💙', '💜'];
|
||||
|
||||
for (let i = 0; i < elementCount; i++) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pride-heart';
|
||||
|
||||
el.innerText = heartEmojis[Math.floor(Math.random() * heartEmojis.length)];
|
||||
el.style.fontSize = `${heartSize}rem`;
|
||||
el.style.left = `${Math.random() * 100}vw`;
|
||||
el.style.animationDuration = `${5 + Math.random() * 5}s`;
|
||||
el.style.animationDelay = `${Math.random() * 5}s`;
|
||||
el.style.marginLeft = `${(Math.random() - 0.5) * 100}px`;
|
||||
|
||||
container.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Initialization
|
||||
function initializePride() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
togglePride();
|
||||
}
|
||||
|
||||
initializePride();
|
||||
26
Jellyfin.Plugin.Seasonals/Web/rain.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.rain-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.raindrop-pure {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background: linear-gradient(to bottom, rgba(200, 200, 255, 0), rgba(200, 200, 255, 0.7));
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
@keyframes pure-rain {
|
||||
0% { transform: translateY(-30vh) translateX(0) rotate(20deg); opacity: 0; }
|
||||
5% { opacity: 1; }
|
||||
95% { opacity: 1; }
|
||||
100% { transform: translateY(110vh) translateX(-40vh) rotate(20deg); opacity: 0; }
|
||||
}
|
||||
77
Jellyfin.Plugin.Seasonals/Web/rain.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// 1. Read Configuration
|
||||
const config = window.SeasonalsPluginConfig?.Rain || {};
|
||||
|
||||
const enabled = config.EnableRain !== undefined ? config.EnableRain : true;
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const elementCount = isMobile ? (config.RaindropCountMobile || 150) : (config.RaindropCount || 300);
|
||||
const rainSpeed = config.RainSpeed || 1.0;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
// 2. Toggle Function
|
||||
function toggleRain() {
|
||||
const container = document.querySelector('.rain-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('Rain hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Rain visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. MutationObserver
|
||||
const observer = new MutationObserver(toggleRain);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
// 4. Element Creation
|
||||
function createElements() {
|
||||
const container = document.querySelector('.rain-container') || document.createElement('div');
|
||||
|
||||
if (!document.querySelector('.rain-container')) {
|
||||
container.className = 'rain-container';
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
for (let i = 0; i < elementCount; i++) {
|
||||
const drop = document.createElement('div');
|
||||
drop.className = 'raindrop-pure';
|
||||
|
||||
drop.style.left = `${Math.random() * 140}vw`;
|
||||
drop.style.top = `${-20 - Math.random() * 50}vh`;
|
||||
|
||||
const duration = (0.5 + Math.random() * 0.5) / (rainSpeed || 1);
|
||||
drop.style.animation = `pure-rain ${duration}s linear infinite`;
|
||||
drop.style.animationDelay = `${Math.random() * 2}s`;
|
||||
drop.style.opacity = Math.random() * 0.5 + 0.3;
|
||||
|
||||
container.appendChild(drop);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Initialization
|
||||
function initializeRain() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
toggleRain();
|
||||
}
|
||||
|
||||
initializeRain();
|
||||
60
Jellyfin.Plugin.Seasonals/Web/resurrection.css
Normal file
@@ -0,0 +1,60 @@
|
||||
.resurrection-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.resurrection-symbol {
|
||||
position: fixed;
|
||||
z-index: 15;
|
||||
top: -15%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
animation-name: resurrection-fall, resurrection-sway;
|
||||
animation-timing-function: linear, ease-in-out;
|
||||
animation-iteration-count: infinite, infinite;
|
||||
will-change: transform, top;
|
||||
}
|
||||
|
||||
.resurrection-symbol img {
|
||||
z-index: 15;
|
||||
height: auto;
|
||||
width: 56px;
|
||||
opacity: 0.95;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 215, 130, 0.5));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.resurrection-symbol img {
|
||||
width: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes resurrection-fall {
|
||||
0% {
|
||||
top: -15%;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes resurrection-sway {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(65px);
|
||||
}
|
||||
}
|
||||
113
Jellyfin.Plugin.Seasonals/Web/resurrection.js
Normal file
@@ -0,0 +1,113 @@
|
||||
const config = window.SeasonalsPluginConfig?.Resurrection || {};
|
||||
|
||||
const enableResurrection = config.EnableResurrection !== undefined ? config.EnableResurrection : true;
|
||||
const enableRandomSymbols = config.EnableRandomSymbols !== undefined ? config.EnableRandomSymbols : true;
|
||||
const enableRandomSymbolsMobile = config.EnableRandomSymbolsMobile !== undefined ? config.EnableRandomSymbolsMobile : false;
|
||||
const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true;
|
||||
const symbolCount = config.SymbolCount || 12;
|
||||
|
||||
let animationEnabled = true;
|
||||
let statusLogged = false;
|
||||
|
||||
const images = [
|
||||
'../Seasonals/Resources/resurrection_images/crosses.png',
|
||||
'../Seasonals/Resources/resurrection_images/palm-branch.png',
|
||||
'../Seasonals/Resources/resurrection_images/draped-cross.png',
|
||||
'../Seasonals/Resources/resurrection_images/empty-tomb.png',
|
||||
'../Seasonals/Resources/resurrection_images/he-is-risen.png',
|
||||
'../Seasonals/Resources/resurrection_images/crown-of-thorns.png',
|
||||
'../Seasonals/Resources/resurrection_images/risen-lord.png',
|
||||
'../Seasonals/Resources/resurrection_images/dove.png'
|
||||
];
|
||||
|
||||
function toggleResurrection() {
|
||||
const container = document.querySelector('.resurrection-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');
|
||||
|
||||
animationEnabled = !(videoPlayer || trailerPlayer || isDashboard || hasUserMenu);
|
||||
container.style.display = animationEnabled ? 'block' : 'none';
|
||||
|
||||
if (!animationEnabled && !statusLogged) {
|
||||
console.log('Resurrection hidden');
|
||||
statusLogged = true;
|
||||
} else if (animationEnabled && statusLogged) {
|
||||
console.log('Resurrection visible');
|
||||
statusLogged = false;
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleResurrection);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
function createSymbol(imageSrc, leftPercent, delaySeconds) {
|
||||
const symbol = document.createElement('div');
|
||||
symbol.className = 'resurrection-symbol';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = imageSrc;
|
||||
img.alt = '';
|
||||
|
||||
symbol.style.left = `${leftPercent}%`;
|
||||
symbol.style.animationDelay = `${delaySeconds}s, ${Math.random() * 3}s`;
|
||||
|
||||
if (enableDifferentDuration) {
|
||||
const fallDuration = Math.random() * 7 + 7;
|
||||
const swayDuration = Math.random() * 4 + 2;
|
||||
symbol.style.animationDuration = `${fallDuration}s, ${swayDuration}s`;
|
||||
}
|
||||
|
||||
symbol.appendChild(img);
|
||||
return symbol;
|
||||
}
|
||||
|
||||
function addSymbols(count) {
|
||||
const container = document.querySelector('.resurrection-container');
|
||||
if (!container || !enableRandomSymbols) return;
|
||||
|
||||
const isDesktop = window.innerWidth > 768;
|
||||
if (!isDesktop && !enableRandomSymbolsMobile) return;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const imageSrc = images[Math.floor(Math.random() * images.length)];
|
||||
const left = Math.random() * 100;
|
||||
const delay = Math.random() * 12;
|
||||
container.appendChild(createSymbol(imageSrc, left, delay));
|
||||
}
|
||||
}
|
||||
|
||||
function initResurrection() {
|
||||
let container = document.querySelector('.resurrection-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'resurrection-container';
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// Place one of each of the 8 provided resurrection images first.
|
||||
images.forEach((imageSrc, index) => {
|
||||
const left = (index + 1) * (100 / (images.length + 1));
|
||||
const delay = Math.random() * 8;
|
||||
container.appendChild(createSymbol(imageSrc, left, delay));
|
||||
});
|
||||
|
||||
const extraCount = Math.max(symbolCount - images.length, 0);
|
||||
addSymbols(extraCount);
|
||||
}
|
||||
|
||||
function initializeResurrection() {
|
||||
if (!enableResurrection) return;
|
||||
initResurrection();
|
||||
toggleResurrection();
|
||||
}
|
||||
|
||||
initializeResurrection();
|
||||
BIN
Jellyfin.Plugin.Seasonals/Web/resurrection_images/crosses.png
Normal file
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 317 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/resurrection_images/dove.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 382 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/resurrection_images/empty-tomb.png
Normal file
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 384 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/resurrection_images/risen-lord.png
Normal file
|
After Width: | Height: | Size: 385 KiB |
@@ -181,18 +181,18 @@ function updateSnowflakes() {
|
||||
|
||||
// credits: flaticon.com
|
||||
const presentImages = [
|
||||
'/Seasonals/Resources/santa_images/gift1.png',
|
||||
'/Seasonals/Resources/santa_images/gift2.png',
|
||||
'/Seasonals/Resources/santa_images/gift3.png',
|
||||
'/Seasonals/Resources/santa_images/gift4.png',
|
||||
'/Seasonals/Resources/santa_images/gift5.png',
|
||||
'/Seasonals/Resources/santa_images/gift6.png',
|
||||
'/Seasonals/Resources/santa_images/gift7.png',
|
||||
'/Seasonals/Resources/santa_images/gift8.png',
|
||||
'../Seasonals/Resources/santa_images/gift1.png',
|
||||
'../Seasonals/Resources/santa_images/gift2.png',
|
||||
'../Seasonals/Resources/santa_images/gift3.png',
|
||||
'../Seasonals/Resources/santa_images/gift4.png',
|
||||
'../Seasonals/Resources/santa_images/gift5.png',
|
||||
'../Seasonals/Resources/santa_images/gift6.png',
|
||||
'../Seasonals/Resources/santa_images/gift7.png',
|
||||
'../Seasonals/Resources/santa_images/gift8.png',
|
||||
];
|
||||
|
||||
// credits: https://www.animatedimages.org/img-animated-santa-claus-image-0420-85884.htm
|
||||
const santaImage = '/Seasonals/Resources/santa_images/santa.gif';
|
||||
const santaImage = '../Seasonals/Resources/santa_images/santa.gif';
|
||||
|
||||
|
||||
function createSantaElement() {
|
||||
|
||||
@@ -1,366 +1,443 @@
|
||||
// theme-configs.js
|
||||
/*
|
||||
* Seasonals Plugin (Client Side Manager Logic)
|
||||
*/
|
||||
|
||||
// theme configurations
|
||||
const themeConfigs = {
|
||||
const ThemeConfigs = {
|
||||
snowflakes: {
|
||||
css: '/Seasonals/Resources/snowflakes.css',
|
||||
js: '/Seasonals/Resources/snowflakes.js',
|
||||
css: '../Seasonals/Resources/snowflakes.css',
|
||||
js: '../Seasonals/Resources/snowflakes.js',
|
||||
containerClass: 'snowflakes'
|
||||
},
|
||||
snowfall: {
|
||||
css: '/Seasonals/Resources/snowfall.css',
|
||||
js: '/Seasonals/Resources/snowfall.js',
|
||||
css: '../Seasonals/Resources/snowfall.css',
|
||||
js: '../Seasonals/Resources/snowfall.js',
|
||||
containerClass: 'snowfall-container'
|
||||
},
|
||||
snowstorm: {
|
||||
css: '/Seasonals/Resources/snowstorm.css',
|
||||
js: '/Seasonals/Resources/snowstorm.js',
|
||||
css: '../Seasonals/Resources/snowstorm.css',
|
||||
js: '../Seasonals/Resources/snowstorm.js',
|
||||
containerClass: 'snowstorm-container'
|
||||
},
|
||||
fireworks: {
|
||||
css: '/Seasonals/Resources/fireworks.css',
|
||||
js: '/Seasonals/Resources/fireworks.js',
|
||||
css: '../Seasonals/Resources/fireworks.css',
|
||||
js: '../Seasonals/Resources/fireworks.js',
|
||||
containerClass: 'fireworks'
|
||||
},
|
||||
halloween: {
|
||||
css: '/Seasonals/Resources/halloween.css',
|
||||
js: '/Seasonals/Resources/halloween.js',
|
||||
css: '../Seasonals/Resources/halloween.css',
|
||||
js: '../Seasonals/Resources/halloween.js',
|
||||
containerClass: 'halloween-container'
|
||||
},
|
||||
hearts: {
|
||||
css: '/Seasonals/Resources/hearts.css',
|
||||
js: '/Seasonals/Resources/hearts.js',
|
||||
css: '../Seasonals/Resources/hearts.css',
|
||||
js: '../Seasonals/Resources/hearts.js',
|
||||
containerClass: 'hearts-container'
|
||||
},
|
||||
christmas: {
|
||||
css: '/Seasonals/Resources/christmas.css',
|
||||
js: '/Seasonals/Resources/christmas.js',
|
||||
css: '../Seasonals/Resources/christmas.css',
|
||||
js: '../Seasonals/Resources/christmas.js',
|
||||
containerClass: 'christmas-container'
|
||||
},
|
||||
santa: {
|
||||
css: '/Seasonals/Resources/santa.css',
|
||||
js: '/Seasonals/Resources/santa.js',
|
||||
css: '../Seasonals/Resources/santa.css',
|
||||
js: '../Seasonals/Resources/santa.js',
|
||||
containerClass: 'santa-container'
|
||||
},
|
||||
autumn: {
|
||||
css: '/Seasonals/Resources/autumn.css',
|
||||
js: '/Seasonals/Resources/autumn.js',
|
||||
css: '../Seasonals/Resources/autumn.css',
|
||||
js: '../Seasonals/Resources/autumn.js',
|
||||
containerClass: 'autumn-container'
|
||||
},
|
||||
easter: {
|
||||
css: '/Seasonals/Resources/easter.css',
|
||||
js: '/Seasonals/Resources/easter.js',
|
||||
css: '../Seasonals/Resources/easter.css',
|
||||
js: '../Seasonals/Resources/easter.js',
|
||||
containerClass: 'easter-container'
|
||||
},
|
||||
resurrection: {
|
||||
css: '../Seasonals/Resources/resurrection.css',
|
||||
js: '../Seasonals/Resources/resurrection.js',
|
||||
containerClass: 'resurrection-container'
|
||||
},
|
||||
summer: {
|
||||
css: '/Seasonals/Resources/summer.css',
|
||||
js: '/Seasonals/Resources/summer.js',
|
||||
css: '../Seasonals/Resources/summer.css',
|
||||
js: '../Seasonals/Resources/summer.js',
|
||||
containerClass: 'summer-container'
|
||||
},
|
||||
spring: {
|
||||
css: '/Seasonals/Resources/spring.css',
|
||||
js: '/Seasonals/Resources/spring.js',
|
||||
css: '../Seasonals/Resources/spring.css',
|
||||
js: '../Seasonals/Resources/spring.js',
|
||||
containerClass: 'spring-container'
|
||||
},
|
||||
carnival: {
|
||||
css: '../Seasonals/Resources/carnival.css',
|
||||
js: '../Seasonals/Resources/carnival.js',
|
||||
containerClass: 'carnival-container'
|
||||
},
|
||||
cherryblossom: {
|
||||
css: '../Seasonals/Resources/cherryblossom.css',
|
||||
js: '../Seasonals/Resources/cherryblossom.js',
|
||||
containerClass: 'cherryblossom-container'
|
||||
},
|
||||
piday: {
|
||||
css: '../Seasonals/Resources/piday.css',
|
||||
js: '../Seasonals/Resources/piday.js',
|
||||
containerClass: 'piday-container'
|
||||
},
|
||||
eurovision: {
|
||||
css: '../Seasonals/Resources/eurovision.css',
|
||||
js: '../Seasonals/Resources/eurovision.js',
|
||||
containerClass: 'eurovision-container'
|
||||
},
|
||||
storm: {
|
||||
css: '../Seasonals/Resources/storm.css',
|
||||
js: '../Seasonals/Resources/storm.js',
|
||||
containerClass: 'storm-container'
|
||||
},
|
||||
pride: {
|
||||
css: '../Seasonals/Resources/pride.css',
|
||||
js: '../Seasonals/Resources/pride.js',
|
||||
containerClass: 'pride-container'
|
||||
},
|
||||
rain: {
|
||||
css: '../Seasonals/Resources/rain.css',
|
||||
js: '../Seasonals/Resources/rain.js',
|
||||
containerClass: 'rain-container'
|
||||
},
|
||||
earthday: {
|
||||
css: '../Seasonals/Resources/earthday.css',
|
||||
js: '../Seasonals/Resources/earthday.js',
|
||||
containerClass: 'earthday-container'
|
||||
},
|
||||
none: {
|
||||
containerClass: 'none'
|
||||
},
|
||||
};
|
||||
|
||||
// determine current theme based on the current month
|
||||
function determineCurrentTheme() {
|
||||
const date = new Date();
|
||||
const month = date.getMonth(); // 0-11
|
||||
const day = date.getDate(); // 1-31
|
||||
const SeasonalSettingsManager = {
|
||||
initialized: false,
|
||||
config: null,
|
||||
|
||||
if ((month === 11 && day >= 28) || (month === 0 && day <= 5)) return 'fireworks'; //new year fireworks december 28 - january 5
|
||||
init(config) {
|
||||
if (this.initialized) return;
|
||||
this.config = config;
|
||||
|
||||
if (month === 1 && day >= 10 && day <= 18) return 'hearts'; // valentine's day february 10 - 18
|
||||
|
||||
if (month === 11 && day >= 22 && day <= 27) return 'santa'; // christmas december 22 - 27
|
||||
// if (month === 11 && day >= 22 && day <= 27) return 'christmas'; // christmas december 22 - 27
|
||||
|
||||
if (month === 11) return 'snowflakes'; // snowflakes december
|
||||
if (month === 0 || month === 1) return 'snowfall'; // snow january, february
|
||||
// if (month === 0 || month === 1) return 'snowstorm'; // snow january, february
|
||||
|
||||
if ((month === 2 && day >= 25) || (month === 3 && day <= 25)) return 'easter'; // easter march 25 - april 25
|
||||
|
||||
//NOT IMPLEMENTED YET
|
||||
//if (month >= 2 && month <= 4) return 'spring'; // spring march, april, may
|
||||
|
||||
//NOT IMPLEMENTED YET
|
||||
//if (month >= 5 && month <= 7) return 'summer'; // summer june, july, august
|
||||
|
||||
if ((month === 9 && day >= 24) || (month === 10 && day <= 5)) return 'halloween'; // halloween october 24 - november 5
|
||||
|
||||
if (month >= 8 && month <= 10) return 'autumn'; // autumn september, october, november
|
||||
|
||||
return 'none'; // Fallback (nothing)
|
||||
}
|
||||
|
||||
// helper to resolve paths for local testing vs production
|
||||
function resolvePath(path) {
|
||||
if (window.location.protocol === 'file:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
return path.replace('/Seasonals/Resources/', './');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// load theme csss
|
||||
function loadThemeCSS(cssPath) {
|
||||
if (!cssPath) return;
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
// link.href = resolvePath(cssPath);
|
||||
link.href = cssPath;
|
||||
|
||||
link.onerror = () => {
|
||||
console.error(`Failed to load CSS: ${cssPath}`);
|
||||
};
|
||||
|
||||
document.body.appendChild(link);
|
||||
console.log(`CSS file "${cssPath}" loaded.`);
|
||||
}
|
||||
|
||||
// load theme js
|
||||
function loadThemeJS(jsPath) {
|
||||
if (!jsPath) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = jsPath;
|
||||
// script.src = resolvePath(jsPath);
|
||||
script.defer = true;
|
||||
|
||||
script.onerror = () => {
|
||||
console.error(`Failed to load JS: ${jsPath}`);
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
console.log(`JS file "${jsPath}" loaded.`);
|
||||
}
|
||||
|
||||
// update theme container class name
|
||||
function updateThemeContainer(containerClass) {
|
||||
// Create container if it doesn't exist
|
||||
let container = document.querySelector('.seasonals-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'seasonals-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
container.className = `seasonals-container ${containerClass}`;
|
||||
console.log(`Seasonals-Container class updated to "${containerClass}".`);
|
||||
}
|
||||
|
||||
// function removeSelf() {
|
||||
// const script = document.currentScript;
|
||||
// if (script) script.parentNode.removeChild(script);
|
||||
// console.log('External script removed:', script);
|
||||
// }
|
||||
|
||||
// initialize theme
|
||||
async function initializeTheme() {
|
||||
|
||||
// Check local user preference
|
||||
const isEnabled = getSavedSetting('seasonals-enabled', 'true') === 'true';
|
||||
if (!isEnabled) {
|
||||
console.log('Seasonals disabled by user preference.');
|
||||
return;
|
||||
}
|
||||
|
||||
const forcedTheme = getSavedSetting('seasonals-theme', 'auto');
|
||||
|
||||
let automateThemeSelection = true;
|
||||
let defaultTheme = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/Seasonals/Config');
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
automateThemeSelection = config.AutomateSeasonSelection;
|
||||
defaultTheme = config.SelectedSeason;
|
||||
window.SeasonalsPluginConfig = config;
|
||||
console.log('Seasonals Config loaded:', config);
|
||||
} else {
|
||||
console.error('Failed to fetch Seasonals config');
|
||||
// Only inject settings if enabled on server by admin
|
||||
if (this.config && this.config.EnableClientSideToggle !== false) {
|
||||
this.injectSettingsIcon();
|
||||
this.initialized = true;
|
||||
console.log("Seasonals: Client-Side Settings Manager initialized.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching Seasonals config:', error);
|
||||
}
|
||||
},
|
||||
|
||||
let currentTheme;
|
||||
getSetting(key, defaultValue) {
|
||||
const value = localStorage.getItem(`seasonals-${key}`);
|
||||
return value !== null ? value : defaultValue;
|
||||
},
|
||||
|
||||
if (forcedTheme !== 'auto') {
|
||||
currentTheme = forcedTheme;
|
||||
console.log(`User forced theme: ${currentTheme}`);
|
||||
} else if (automateThemeSelection === false) {
|
||||
currentTheme = defaultTheme;
|
||||
} else {
|
||||
currentTheme = determineCurrentTheme();
|
||||
}
|
||||
setSetting(key, value) {
|
||||
localStorage.setItem(`seasonals-${key}`, value);
|
||||
},
|
||||
|
||||
console.log(`Selected theme: ${currentTheme}`);
|
||||
createIcon() {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'paper-icon-button-light headerButton seasonal-settings-button';
|
||||
button.title = 'Seasonal Settings';
|
||||
// button.innerHTML = '<span class="material-icons">ac_unit</span>';
|
||||
button.innerHTML = '<img src="../Seasonals/Resources/assets/logo_SW.svg" draggable="false" style="width: 24px; height: 24px; vertical-align: middle; pointer-events: none;">';
|
||||
button.style.verticalAlign = 'middle';
|
||||
|
||||
if (!currentTheme || currentTheme === 'none') {
|
||||
console.log('No theme selected.');
|
||||
return;
|
||||
}
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleSettingsPopup(button);
|
||||
});
|
||||
|
||||
const theme = themeConfigs[currentTheme];
|
||||
return button;
|
||||
},
|
||||
|
||||
if (!theme) {
|
||||
console.error(`Theme "${currentTheme}" not found.`);
|
||||
return;
|
||||
}
|
||||
updateThemeContainer(theme.containerClass);
|
||||
injectSettingsIcon() {
|
||||
const observer = new MutationObserver((mutations, obs) => {
|
||||
const headerRight = document.querySelector('.headerRight');
|
||||
if (headerRight && !document.querySelector('.seasonal-settings-button')) {
|
||||
const icon = this.createIcon();
|
||||
headerRight.prepend(icon);
|
||||
}
|
||||
});
|
||||
|
||||
if (theme.css) loadThemeCSS(theme.css);
|
||||
if (theme.js) loadThemeJS(theme.js);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
},
|
||||
|
||||
console.log(`Theme "${currentTheme}" loaded.`);
|
||||
}
|
||||
createPopup(anchorElement) {
|
||||
const existing = document.querySelector('.seasonal-settings-popup');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'seasonal-settings-popup dialog';
|
||||
|
||||
initializeTheme();
|
||||
Object.assign(popup.style, {
|
||||
position: 'fixed',
|
||||
zIndex: '10000',
|
||||
backgroundColor: '#202020',
|
||||
padding: '1em',
|
||||
borderRadius: '0.3em',
|
||||
boxShadow: '0 0 20px rgba(0,0,0,0.5)',
|
||||
minWidth: '200px',
|
||||
color: '#fff',
|
||||
maxWidth: '250px'
|
||||
});
|
||||
|
||||
const rect = anchorElement.getBoundingClientRect();
|
||||
|
||||
// User UI Seasonal Settings
|
||||
// Positioning logic
|
||||
let rightPos = window.innerWidth - rect.right;
|
||||
if (window.innerWidth < 450 || (window.innerWidth - rightPos) < 260) {
|
||||
popup.style.right = '1rem';
|
||||
popup.style.left = 'auto';
|
||||
} else {
|
||||
popup.style.right = `${rightPos}px`;
|
||||
popup.style.left = 'auto';
|
||||
}
|
||||
popup.style.top = `${rect.bottom + 10}px`;
|
||||
|
||||
function getSavedSetting(key, defaultValue) {
|
||||
const value = localStorage.getItem(key);
|
||||
return value !== null ? value : defaultValue;
|
||||
}
|
||||
// Popup HTML
|
||||
let html = `
|
||||
<h3 style="margin-top:0; margin-bottom:1em; border-bottom:1px solid #444; padding-bottom:0.5em;">Seasonal Settings</h3>
|
||||
|
||||
function setSavedSetting(key, value) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
function createSettingsIcon() {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'paper-icon-button-light headerButton seasonal-settings-button';
|
||||
button.title = 'Seasonal Settings';
|
||||
button.innerHTML = '<span class="material-icons">ac_unit</span>';
|
||||
button.style.verticalAlign = 'middle';
|
||||
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleSettingsPopup(button);
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
function createSettingsPopup(anchorElement) {
|
||||
const existing = document.querySelector('.seasonal-settings-popup');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'seasonal-settings-popup dialog';
|
||||
|
||||
Object.assign(popup.style, {
|
||||
position: 'fixed',
|
||||
zIndex: '10000',
|
||||
backgroundColor: '#202020',
|
||||
padding: '1.5em',
|
||||
borderRadius: '1em',
|
||||
boxShadow: '0 0 20px rgba(0,0,0,0.5)',
|
||||
minWidth: '250px',
|
||||
color: '#fff',
|
||||
maxWidth: '300px'
|
||||
});
|
||||
|
||||
const rect = anchorElement.getBoundingClientRect();
|
||||
popup.style.top = `${rect.bottom + 10}px`;
|
||||
popup.style.right = `${window.innerWidth - rect.right}px`;
|
||||
|
||||
popup.innerHTML = `
|
||||
<div class="checkboxContainer checkboxContainer-withDescription" style="margin-bottom: 1.5em;">
|
||||
<div class="checkboxContainer checkboxContainer-withDescription" style="margin-bottom: 0.5em;">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="seasonal-enable-toggle" type="checkbox" is="emby-checkbox" class="emby-checkbox" />
|
||||
<span class="checkboxLabel">Enable Seasonals</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<div class="selectContainer" style="margin-bottom: 0.5em;">
|
||||
<label class="selectLabel" for="seasonal-theme-select" style="margin-bottom: 0.5em; display: block; color: inherit;">Force Theme</label>
|
||||
<select is="emby-select" id="seasonal-theme-select" class="emby-select-withcolor emby-select" style="width: 100%; padding: 0.5em; background-color: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.1); color: inherit; border-radius: 4px;">
|
||||
<option value="auto">Auto (Date Based)</option>
|
||||
<select id="seasonal-theme-select" class="emby-select" style="width: 100%; padding: 0.5em; background-color: #333; border: 1px solid #444; color: #fff; border-radius: 4px;">
|
||||
<option value="auto">Server-Side</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
`;
|
||||
|
||||
// Populate Select Options
|
||||
const themeSelect = popup.querySelector('#seasonal-theme-select');
|
||||
Object.keys(themeConfigs).forEach(key => {
|
||||
if (key === 'none') return;
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
// Capitalize first letter
|
||||
option.textContent = key.charAt(0).toUpperCase() + key.slice(1);
|
||||
themeSelect.appendChild(option);
|
||||
});
|
||||
popup.innerHTML = html;
|
||||
|
||||
// Set Initial Values
|
||||
const enabledCheckbox = popup.querySelector('#seasonal-enable-toggle');
|
||||
enabledCheckbox.checked = getSavedSetting('seasonals-enabled', 'true') === 'true';
|
||||
// Populate Select Options
|
||||
const themeSelect = popup.querySelector('#seasonal-theme-select');
|
||||
Object.keys(ThemeConfigs).forEach(key => {
|
||||
if (key === 'none') return;
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
option.textContent = key.charAt(0).toUpperCase() + key.slice(1);
|
||||
themeSelect.appendChild(option);
|
||||
});
|
||||
|
||||
themeSelect.value = getSavedSetting('seasonals-theme', 'auto');
|
||||
// Set Initial Values
|
||||
const enabledCheckbox = popup.querySelector('#seasonal-enable-toggle');
|
||||
enabledCheckbox.checked = this.getSetting('enabled', 'true') === 'true';
|
||||
themeSelect.value = this.getSetting('theme', 'auto');
|
||||
|
||||
// Event Listeners
|
||||
enabledCheckbox.addEventListener('change', (e) => {
|
||||
setSavedSetting('seasonals-enabled', e.target.checked);
|
||||
location.reload();
|
||||
});
|
||||
// Event Listeners
|
||||
enabledCheckbox.addEventListener('change', (e) => {
|
||||
this.setSetting('enabled', e.target.checked);
|
||||
location.reload();
|
||||
});
|
||||
|
||||
themeSelect.addEventListener('change', (e) => {
|
||||
setSavedSetting('seasonals-theme', e.target.value);
|
||||
location.reload();
|
||||
});
|
||||
themeSelect.addEventListener('change', (e) => {
|
||||
this.setSetting('theme', e.target.value);
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// Close on click outside
|
||||
const closeHandler = (e) => {
|
||||
if (!popup.contains(e.target) && e.target !== anchorElement && !anchorElement.contains(e.target)) {
|
||||
popup.remove();
|
||||
document.removeEventListener('click', closeHandler);
|
||||
// Close on outside click
|
||||
const closeHandler = (e) => {
|
||||
if (!popup.contains(e.target) && e.target !== anchorElement && !anchorElement.contains(e.target)) {
|
||||
popup.remove();
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
|
||||
document.body.appendChild(popup);
|
||||
},
|
||||
|
||||
toggleSettingsPopup(anchorElement) {
|
||||
const existing = document.querySelector('.seasonal-settings-popup');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
} else {
|
||||
this.createPopup(anchorElement);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
|
||||
document.body.appendChild(popup);
|
||||
}
|
||||
|
||||
function toggleSettingsPopup(anchorElement) {
|
||||
const existing = document.querySelector('.seasonal-settings-popup');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
} else {
|
||||
createSettingsPopup(anchorElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function injectSettingsIcon() {
|
||||
const observer = new MutationObserver((mutations, obs) => {
|
||||
// Check if admin has enabled this feature
|
||||
if (window.SeasonalsPluginConfig && window.SeasonalsPluginConfig.EnableClientSideToggle === false) {
|
||||
const SeasonalsManager = {
|
||||
config: null,
|
||||
|
||||
async init() {
|
||||
// Fetch Config
|
||||
try {
|
||||
const response = await fetch('../Seasonals/Config');
|
||||
if (response.ok) {
|
||||
this.config = await response.json();
|
||||
window.SeasonalsPluginConfig = this.config;
|
||||
|
||||
if (this.config.IsEnabled === false) {
|
||||
console.log('Seasonals: Plugin is disabled globally.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Seasonals: Seasonals Config loaded:', this.config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Seasonals: Error fetching Seasonals config:', error);
|
||||
}
|
||||
|
||||
// Initialize Settings UI
|
||||
SeasonalSettingsManager.init(this.config);
|
||||
|
||||
// User Preference Check
|
||||
const isEnabled = SeasonalSettingsManager.getSetting('enabled', 'true') === 'true';
|
||||
if (!isEnabled) {
|
||||
console.log('Seasonals: Disabled by user preference.');
|
||||
return;
|
||||
}
|
||||
|
||||
const headerRight = document.querySelector('.headerRight');
|
||||
if (headerRight && !document.querySelector('.seasonal-settings-button')) {
|
||||
const icon = createSettingsIcon();
|
||||
headerRight.prepend(icon);
|
||||
// obs.disconnect();
|
||||
// Determine Theme
|
||||
const themeName = this.selectTheme();
|
||||
console.log(`Seasonals: Selected theme: ${themeName}`);
|
||||
|
||||
if (!themeName || themeName === 'none') {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
// Apply Theme
|
||||
this.applyTheme(themeName);
|
||||
},
|
||||
|
||||
injectSettingsIcon();
|
||||
selectTheme() {
|
||||
// Check local override
|
||||
const forcedTheme = SeasonalSettingsManager.getSetting('theme', 'auto');
|
||||
if (forcedTheme !== 'auto') {
|
||||
console.log(`Seasonals: User forced theme: ${forcedTheme}`);
|
||||
return forcedTheme;
|
||||
}
|
||||
|
||||
const automate = this.config ? this.config.AutomateSeasonSelection : true;
|
||||
const defaultTheme = this.config ? this.config.SelectedSeason : 'none';
|
||||
|
||||
if (!automate) {
|
||||
return defaultTheme;
|
||||
}
|
||||
|
||||
return this.determineCurrentThemeDate();
|
||||
},
|
||||
|
||||
determineCurrentThemeDate() {
|
||||
var rules = [];
|
||||
try {
|
||||
rules = JSON.parse(this.config.SeasonalRules || "[]");
|
||||
} catch (e) {
|
||||
console.error("Seasonals: Error parsing SeasonalRules", e);
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
// Fallback to empty/none if no rules are defined (though default should exist)
|
||||
console.log("Seasonals: No auto-selection rules found.");
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const date = new Date();
|
||||
const month = date.getMonth() + 1; // 1-12
|
||||
const day = date.getDate(); // 1-31
|
||||
|
||||
for (var i = 0; i < rules.length; i++) {
|
||||
var rule = rules[i];
|
||||
if (this.isDateInRange(day, month, rule.StartDay, rule.StartMonth, rule.EndDay, rule.EndMonth)) {
|
||||
console.log(`Seasonals: Match found for rule "${rule.Name}" (${rule.Theme})`);
|
||||
return rule.Theme;
|
||||
}
|
||||
}
|
||||
|
||||
return 'none'; // No rule matched
|
||||
},
|
||||
|
||||
isDateInRange: function(day, month, startDay, startMonth, endDay, endMonth) {
|
||||
if (startMonth > endMonth) {
|
||||
// Wrapping year (e.g. Dec to Jan)
|
||||
return this.isDateAfterOrEqual(day, month, startDay, startMonth) ||
|
||||
this.isDateBeforeOrEqual(day, month, endDay, endMonth);
|
||||
} else {
|
||||
// Normal range
|
||||
return this.isDateAfterOrEqual(day, month, startDay, startMonth) &&
|
||||
this.isDateBeforeOrEqual(day, month, endDay, endMonth);
|
||||
}
|
||||
},
|
||||
|
||||
isDateAfterOrEqual: function(day, month, targetDay, targetMonth) {
|
||||
if (month > targetMonth) return true;
|
||||
if (month === targetMonth && day >= targetDay) return true;
|
||||
return false;
|
||||
},
|
||||
|
||||
isDateBeforeOrEqual: function(day, month, targetDay, targetMonth) {
|
||||
if (month < targetMonth) return true;
|
||||
if (month === targetMonth && day <= targetDay) return true;
|
||||
return false;
|
||||
},
|
||||
|
||||
applyTheme(themeName) {
|
||||
const theme = ThemeConfigs[themeName];
|
||||
if (!theme) {
|
||||
console.error(`Seasonals: Theme "${themeName}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateThemeContainer(theme.containerClass);
|
||||
|
||||
if (theme.css) this.loadResource('css', theme.css);
|
||||
if (theme.js) this.loadResource('js', theme.js);
|
||||
|
||||
console.log(`Seasonals: Theme "${themeName}" applied.`);
|
||||
},
|
||||
|
||||
updateThemeContainer(containerClass) {
|
||||
let container = document.querySelector('.seasonals-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'seasonals-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
container.className = `seasonals-container ${containerClass}`;
|
||||
},
|
||||
|
||||
// helper to resolve paths for local testing vs production
|
||||
resolvePath(path) {
|
||||
if (window.location.protocol === 'file:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
return path.replace('/Seasonals/Resources/', './');
|
||||
}
|
||||
return path;
|
||||
},
|
||||
|
||||
loadResource(type, path) {
|
||||
if (!path) return;
|
||||
|
||||
if (type === 'css') {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = path;
|
||||
// link.href = resolvePath(cssPath);
|
||||
link.onerror = () => console.error(`Seasonals: Failed to load CSS: ${path}`);
|
||||
document.body.appendChild(link);
|
||||
} else if (type === 'js') {
|
||||
const script = document.createElement('script');
|
||||
script.src = path;
|
||||
// script.src = resolvePath(jsPath);
|
||||
script.defer = true;
|
||||
script.onerror = () => console.error(`Seasonals: Failed to load JS: ${path}`);
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
SeasonalsManager.init();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
#snowfallCanvas {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.snowflake {
|
||||
@@ -37,7 +38,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
top: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
top: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
#snowfallCanvas {
|
||||
|
||||
255
Jellyfin.Plugin.Seasonals/Web/spring.css
Normal file
@@ -0,0 +1,255 @@
|
||||
.spring-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Grass Container (Wrapper) */
|
||||
.spring-grass-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
pointer-events: none;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
/* HTML Grass Overlayer */
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes spring-grass-sway {
|
||||
0% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(8deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* SVG Meadow Layer */
|
||||
.spring-meadow-layer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: spring-grow-meadow 3s cubic-bezier(0.1, 0.8, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
.spring-meadow {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@keyframes spring-grow-meadow {
|
||||
0% { transform: translateY(100%); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 0.95; }
|
||||
}
|
||||
|
||||
.spring-sway {
|
||||
transform-origin: bottom center;
|
||||
animation: spring-meadow-sway 4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes spring-meadow-sway {
|
||||
0% { transform: skewX(-2deg); }
|
||||
100% { transform: skewX(2deg); }
|
||||
}
|
||||
|
||||
/* Birds */
|
||||
.spring-bird {
|
||||
position: static !important;
|
||||
display: block;
|
||||
z-index: 1001;
|
||||
/* MARK: BIRD SIZE */
|
||||
width: 80px;
|
||||
height: auto;
|
||||
will-change: transform;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/* Butterflies */
|
||||
.spring-butterfly {
|
||||
position: static !important;
|
||||
display: block;
|
||||
z-index: 1001;
|
||||
/* MARK: BUTTERFLY SIZE */
|
||||
width: 40px;
|
||||
height: auto;
|
||||
will-change: transform;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/* Bee */
|
||||
.spring-bee {
|
||||
position: static !important;
|
||||
display: block;
|
||||
z-index: 1001;
|
||||
/* MARK: BEE SIZE */
|
||||
width: 30px;
|
||||
height: auto;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Ladybug */
|
||||
.spring-ladybug-gif {
|
||||
position: static !important;
|
||||
display: block;
|
||||
/* MARK: LADYBUG SIZE */
|
||||
width: 30px;
|
||||
height: auto;
|
||||
will-change: transform;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
--bug-rotation: -55deg;
|
||||
}
|
||||
|
||||
.spring-ladybug-wrapper {
|
||||
z-index: 1002;
|
||||
}
|
||||
|
||||
/* Generic Wrappers */
|
||||
.spring-anim-wrapper {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
will-change: transform;
|
||||
top: 0; left: 0;
|
||||
}
|
||||
|
||||
.spring-align-y {
|
||||
width: 100%; height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.spring-mirror-wrapper {
|
||||
width: 100%; height: 100%;
|
||||
will-change: transform;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
|
||||
@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; transform: rotate(var(--beam-rotation, 45deg)) scaleX(0.8); }
|
||||
50% { opacity: 0.6; transform: rotate(var(--beam-rotation, 45deg)) scaleX(1.2); }
|
||||
100% { opacity: 0; transform: rotate(var(--beam-rotation, 45deg)) scaleX(0.8); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Wrapper animations (Flight across screen) */
|
||||
@keyframes spring-fly-right-wrapper {
|
||||
0% { transform: translateX(-10vw); }
|
||||
100% { transform: translateX(110vw); }
|
||||
}
|
||||
|
||||
@keyframes spring-fly-left-wrapper {
|
||||
0% { transform: translateX(110vw); }
|
||||
100% { transform: translateX(-10vw); }
|
||||
}
|
||||
|
||||
/* Vertical Drift for Sloped Flight */
|
||||
@keyframes spring-vertical-drift {
|
||||
0% { transform: translateY(var(--start-y, 10vh)); }
|
||||
100% { transform: translateY(var(--end-y, 10vh)); }
|
||||
}
|
||||
|
||||
/* Inner animations (Bobbing/Fluttering) */
|
||||
@keyframes spring-bob {
|
||||
0% { transform: translateY(0); }
|
||||
50% { transform: translateY(-20px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes spring-flutter {
|
||||
0% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-5px) rotate(5deg); }
|
||||
50% { transform: translateY(0) rotate(0deg); }
|
||||
75% { transform: translateY(5px) rotate(-5deg); }
|
||||
100% { transform: translateY(0) rotate(0deg); }
|
||||
}
|
||||
|
||||
/* Bee Buzz - Reduced Intensity */
|
||||
@keyframes spring-buzz {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(2px, -2px); }
|
||||
50% { transform: translate(0, 2px); }
|
||||
75% { transform: translate(-2px, -2px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* Ladybug Walk (Wrapper handles X) */
|
||||
@keyframes spring-walk-right {
|
||||
0% { transform: translateX(-10vw); }
|
||||
100% { transform: translateX(110vw); }
|
||||
}
|
||||
|
||||
@keyframes spring-walk-left {
|
||||
0% { transform: translateX(110vw); }
|
||||
100% { transform: translateX(-10vw); }
|
||||
}
|
||||
|
||||
/* Ladybug Crawl (Inner Wobble) */
|
||||
@keyframes spring-crawl {
|
||||
0% { transform: translateY(0) rotate(var(--bug-rotation, 0deg)); }
|
||||
25% { transform: translateY(-3px) rotate(calc(var(--bug-rotation, 0deg) + 8deg)); }
|
||||
50% { transform: translateY(0) rotate(var(--bug-rotation, 0deg)); }
|
||||
75% { transform: translateY(-3px) rotate(calc(var(--bug-rotation, 0deg) - 8deg)); }
|
||||
100% { transform: translateY(0) rotate(var(--bug-rotation, 0deg)); }
|
||||
}
|
||||
461
Jellyfin.Plugin.Seasonals/Web/spring.js
Normal file
@@ -0,0 +1,461 @@
|
||||
const config = window.SeasonalsPluginConfig?.Spring || {};
|
||||
|
||||
const spring = config.EnableSpring !== undefined ? config.EnableSpring : true; // Enable/disable spring
|
||||
const pollenCount = config.PollenCount || 30; // Number of pollen particles
|
||||
const sunbeamCount = config.SunbeamCount || 5; // Number of sunbeams
|
||||
const enableSunbeams = config.EnableSpringSunbeams !== undefined ? config.EnableSpringSunbeams : true; // Enable/disable sunbeams
|
||||
const birdCount = config.BirdCount !== undefined ? config.BirdCount : 3; // Number of birds
|
||||
const butterflyCount = config.ButterflyCount !== undefined ? config.ButterflyCount : 4; // Number of butterflies
|
||||
const beeCount = config.BeeCount !== undefined ? config.BeeCount : 2; // Number of bees
|
||||
const ladybugCount = config.LadybugCount !== undefined ? config.LadybugCount : 2; // Number of ladybugs
|
||||
const randomSpring = config.EnableRandomSpring !== undefined ? config.EnableRandomSpring : true; // Enable random spring objects
|
||||
|
||||
const birdImages = [
|
||||
'../Seasonals/Resources/spring_assets/Bird_1.gif',
|
||||
'../Seasonals/Resources/spring_assets/Bird_2.gif',
|
||||
'../Seasonals/Resources/spring_assets/Bird_3.gif'
|
||||
];
|
||||
|
||||
const butterflyImages = [
|
||||
'../Seasonals/Resources/spring_assets/Butterfly_1.gif',
|
||||
'../Seasonals/Resources/spring_assets/Butterfly_2.gif'
|
||||
];
|
||||
|
||||
const beeImage = '../Seasonals/Resources/spring_assets/Bee.gif';
|
||||
const ladybugImage = '../Seasonals/Resources/spring_assets/ladybug.gif';
|
||||
|
||||
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 createPollen(container) {
|
||||
const pollen = document.createElement('div');
|
||||
pollen.classList.add('spring-pollen');
|
||||
|
||||
// MARK: POLLEN START VERTICAL POSITION (in %)
|
||||
const startY = Math.random() * 60 + 20;
|
||||
pollen.style.top = `${startY}%`;
|
||||
pollen.style.left = `${Math.random() * 100}%`;
|
||||
|
||||
// MARK: POLLEN SIZE
|
||||
const size = Math.random() * 3 + 1; // 1-4px
|
||||
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 spawnSunbeamGroup(container, count) {
|
||||
if (!enableSunbeams) return;
|
||||
|
||||
const rotate = Math.random() * 30 - 15 + 45;
|
||||
let beamsActive = count;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const beam = document.createElement('div');
|
||||
beam.classList.add('spring-sunbeam');
|
||||
|
||||
const left = Math.random() * 100;
|
||||
beam.style.left = `${left}%`;
|
||||
|
||||
// MARK: SUNBEAM WIDTH (in px)
|
||||
const width = Math.random() * 12 + 8; // 8-20px wide
|
||||
beam.style.width = `${width}px`;
|
||||
|
||||
beam.style.setProperty('--beam-rotation', `${rotate}deg`);
|
||||
|
||||
const duration = Math.random() * 7 + 8; // 8-15s
|
||||
beam.style.animation = `spring-beam-pulse ${duration}s ease-in-out forwards`;
|
||||
|
||||
beam.style.animationDelay = `${Math.random() * 3}s`;
|
||||
|
||||
beam.addEventListener('animationend', () => {
|
||||
beam.remove();
|
||||
beamsActive--;
|
||||
if (beamsActive === 0) {
|
||||
spawnSunbeamGroup(container, count);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
grassContainer.innerHTML = '';
|
||||
|
||||
let pathsBg = '';
|
||||
let pathsFg = '';
|
||||
const w = window.innerWidth;
|
||||
const hSVG = 80;
|
||||
|
||||
// 1. Generate Straight Line HTML-Style Grass (converted to SVG Paths)
|
||||
const bladeCount = w / 5; // Reduced from w/3
|
||||
for (let i = 0; i < bladeCount; i++) {
|
||||
const height = Math.random() * 40 + 20; // 20-60px height
|
||||
const x = i * 5 + Math.random() * 3;
|
||||
|
||||
const hue = 100 + Math.random() * 40;
|
||||
const color = `hsl(${hue}, 60%, 40%)`;
|
||||
|
||||
const line = `<line x1="${x}" y1="${hSVG}" x2="${x}" y2="${hSVG - height}" stroke="${color}" stroke-width="2" />`;
|
||||
// ~66% chance to be in background (1001), 33% foreground (1003)
|
||||
if (Math.random() > 0.33) pathsBg += line; else pathsFg += line;
|
||||
}
|
||||
|
||||
// 2. Generate Curved Earth-Day Style Grass
|
||||
for (let i = 0; i < 200; i++) { // Reduced from 400
|
||||
const x = Math.random() * w;
|
||||
const h = 20 + Math.random() * 50;
|
||||
const cY = hSVG - h;
|
||||
const bend = x + (Math.random() * 40 - 20);
|
||||
const color = Math.random() > 0.5 ? '#4caf50' : '#45a049';
|
||||
const width = 1 + Math.random() * 2;
|
||||
const path = `<path d="M ${x} ${hSVG} Q ${bend} ${cY+20} ${bend} ${cY}" stroke="${color}" stroke-width="${width}" fill="none"/>`;
|
||||
// ~66% chance to be in background (1001), 33% foreground (1003)
|
||||
if (Math.random() > 0.33) pathsBg += path; else pathsFg += path;
|
||||
}
|
||||
|
||||
// 3. Generate SVG Flowers
|
||||
const colors = ['#FF69B4', '#FFD700', '#87CEFA', '#FF4500', '#BA55D3', '#FFA500', '#FF1493', '#FFFFFF'];
|
||||
const flowerCount = Math.floor(w / 40); // Reduced from w/30
|
||||
for (let i = 0; i < flowerCount; i++) {
|
||||
const x = 10 + Math.random() * (w - 20);
|
||||
const y = 10 + Math.random() * 40; // 10-50px from top of SVG
|
||||
const col = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
let flower = '';
|
||||
// Stem
|
||||
flower += `<path d="M ${x} ${hSVG} Q ${x - 5 + Math.random() * 10} ${y+15} ${x} ${y}" stroke="#2e7d32" stroke-width="1.5" fill="none"/>`;
|
||||
|
||||
// Petals
|
||||
const r = 2 + Math.random() * 1.5;
|
||||
flower += `<circle cx="${x-r}" cy="${y-r}" r="${r}" fill="${col}"/>`;
|
||||
flower += `<circle cx="${x+r}" cy="${y-r}" r="${r}" fill="${col}"/>`;
|
||||
flower += `<circle cx="${x-r}" cy="${y+r}" r="${r}" fill="${col}"/>`;
|
||||
flower += `<circle cx="${x+r}" cy="${y+r}" r="${r}" fill="${col}"/>`;
|
||||
// Center
|
||||
flower += `<circle cx="${x}" cy="${y}" r="${r*0.7}" fill="#FFF8DC"/>`;
|
||||
|
||||
// ~66% chance to be in background (1001), 33% foreground (1003)
|
||||
if (Math.random() > 0.33) pathsBg += flower; else pathsFg += flower;
|
||||
}
|
||||
|
||||
// Inject purely SVG based grass container
|
||||
grassContainer.innerHTML = `
|
||||
<div class="spring-meadow-layer" style="z-index: 1001;">
|
||||
<svg class="spring-meadow" viewBox="0 0 ${w} ${hSVG}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="spring-sway">
|
||||
${pathsBg}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="spring-meadow-layer" style="z-index: 1003;">
|
||||
<svg class="spring-meadow" viewBox="0 0 ${w} ${hSVG}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="spring-sway" style="animation-delay: -2s;">
|
||||
${pathsFg}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 (enableSunbeams) {
|
||||
spawnSunbeamGroup(container, sunbeamCount);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeSpring() {
|
||||
if (!spring) {
|
||||
console.warn('Spring is disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
initSpringObjects();
|
||||
toggleSpring();
|
||||
|
||||
const container = document.querySelector('.spring-container');
|
||||
if (container) {
|
||||
if (randomSpring) {
|
||||
// Add Pollen
|
||||
for (let i = 0; i < pollenCount; i++) {
|
||||
createPollen(container);
|
||||
}
|
||||
|
||||
// Add Birds
|
||||
for (let i = 0; i < birdCount; i++) {
|
||||
setTimeout(() => createBird(container), Math.random() * 1000); // 0-1s desync
|
||||
}
|
||||
// Add Butterflies
|
||||
for (let i = 0; i < butterflyCount; i++) {
|
||||
setTimeout(() => createButterfly(container), Math.random() * 1000); // 0-1s desync
|
||||
}
|
||||
// Add Bees
|
||||
for (let i = 0; i < beeCount; i++) {
|
||||
setTimeout(() => createBee(container), Math.random() * 1000); // 0-1s desync
|
||||
}
|
||||
// Add Ladybugs
|
||||
for (let i = 0; i < ladybugCount; i++) {
|
||||
setTimeout(() => createLadybugGif(container), Math.random() * 1000); // 0-1s desync
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createBird(container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('spring-anim-wrapper');
|
||||
wrapper.classList.add('spring-bird-wrapper');
|
||||
|
||||
const alignY = document.createElement('div');
|
||||
alignY.classList.add('spring-align-y');
|
||||
|
||||
const mirror = document.createElement('div');
|
||||
mirror.classList.add('spring-mirror-wrapper');
|
||||
|
||||
const bird = document.createElement('img');
|
||||
bird.classList.add('spring-bird');
|
||||
|
||||
const randomSrc = birdImages[Math.floor(Math.random() * birdImages.length)];
|
||||
bird.src = randomSrc;
|
||||
|
||||
const direction = Math.random() > 0.5 ? 'right' : 'left';
|
||||
// MARK: BIRD SPEED (10-15s)
|
||||
const duration = Math.random() * 5 + 10;
|
||||
|
||||
// MARK: BIRD HEIGHT RANGE (in vh)
|
||||
const startY = Math.random() * 55 + 5; // Start 5-60vh
|
||||
const endY = Math.random() * 55 + 5; // End 5-60vh
|
||||
alignY.style.setProperty('--start-y', `${startY}vh`);
|
||||
alignY.style.setProperty('--end-y', `${endY}vh`);
|
||||
|
||||
if (direction === 'right') {
|
||||
wrapper.style.animation = `spring-fly-right-wrapper ${duration}s linear forwards`;
|
||||
mirror.style.transform = 'scaleX(-1)';
|
||||
} else {
|
||||
wrapper.style.animation = `spring-fly-left-wrapper ${duration}s linear forwards`;
|
||||
mirror.style.transform = 'scaleX(1)';
|
||||
}
|
||||
alignY.style.animation = `spring-vertical-drift ${duration}s linear forwards`;
|
||||
|
||||
wrapper.addEventListener('animationend', (e) => {
|
||||
if (e.animationName.includes('fly-')) {
|
||||
wrapper.remove();
|
||||
createBird(container);
|
||||
}
|
||||
});
|
||||
|
||||
bird.style.animation = `spring-bob 2s ease-in-out infinite`;
|
||||
|
||||
mirror.appendChild(bird);
|
||||
alignY.appendChild(mirror);
|
||||
wrapper.appendChild(alignY);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function createButterfly(container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('spring-anim-wrapper');
|
||||
wrapper.classList.add('spring-butterfly-wrapper');
|
||||
|
||||
const alignY = document.createElement('div');
|
||||
alignY.classList.add('spring-align-y');
|
||||
|
||||
const mirror = document.createElement('div');
|
||||
mirror.classList.add('spring-mirror-wrapper');
|
||||
|
||||
const butterfly = document.createElement('img');
|
||||
butterfly.classList.add('spring-butterfly');
|
||||
|
||||
const randomSrc = butterflyImages[Math.floor(Math.random() * butterflyImages.length)];
|
||||
butterfly.src = randomSrc;
|
||||
|
||||
const duration = Math.random() * 15 + 25;
|
||||
const direction = Math.random() > 0.5 ? 'right' : 'left';
|
||||
|
||||
if (direction === 'right') {
|
||||
wrapper.style.animation = `spring-fly-right-wrapper ${duration}s linear forwards`;
|
||||
mirror.style.transform = 'scaleX(1)';
|
||||
} else {
|
||||
wrapper.style.animation = `spring-fly-left-wrapper ${duration}s linear forwards`;
|
||||
mirror.style.transform = 'scaleX(-1)';
|
||||
}
|
||||
|
||||
wrapper.addEventListener('animationend', (e) => {
|
||||
if (e.animationName.includes('fly-')) {
|
||||
wrapper.remove();
|
||||
createButterfly(container);
|
||||
}
|
||||
});
|
||||
|
||||
// MARK: BUTTERFLY FLUTTER RHYTHM
|
||||
butterfly.style.animation = `spring-flutter 3s ease-in-out infinite`;
|
||||
butterfly.style.animationDelay = `-${Math.random() * 3}s`;
|
||||
|
||||
// MARK: BUTTERFLY HEIGHT (in vh)
|
||||
const top = Math.random() * 35 + 30; // 30-65vh
|
||||
alignY.style.transform = `translateY(${top}vh)`;
|
||||
|
||||
mirror.appendChild(butterfly);
|
||||
alignY.appendChild(mirror);
|
||||
wrapper.appendChild(alignY);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function createBee(container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('spring-anim-wrapper');
|
||||
wrapper.classList.add('spring-bee-wrapper');
|
||||
|
||||
const alignY = document.createElement('div');
|
||||
alignY.classList.add('spring-align-y');
|
||||
|
||||
const mirror = document.createElement('div');
|
||||
mirror.classList.add('spring-mirror-wrapper');
|
||||
|
||||
const bee = document.createElement('img');
|
||||
bee.classList.add('spring-bee');
|
||||
bee.src = beeImage;
|
||||
|
||||
const duration = Math.random() * 10 + 15;
|
||||
const direction = Math.random() > 0.5 ? 'right' : 'left';
|
||||
|
||||
if (direction === 'right') {
|
||||
wrapper.style.animation = `spring-fly-right-wrapper ${duration}s linear forwards`;
|
||||
mirror.style.transform = 'scaleX(1)';
|
||||
} else {
|
||||
wrapper.style.animation = `spring-fly-left-wrapper ${duration}s linear forwards`;
|
||||
mirror.style.transform = 'scaleX(-1)';
|
||||
}
|
||||
|
||||
wrapper.addEventListener('animationend', (e) => {
|
||||
if (e.animationName.includes('fly-')) {
|
||||
wrapper.remove();
|
||||
createBee(container);
|
||||
}
|
||||
});
|
||||
|
||||
// MARK: BEE HEIGHT (in vh)
|
||||
const top = Math.random() * 60 + 20; // 20-80vh
|
||||
alignY.style.transform = `translateY(${top}vh)`;
|
||||
|
||||
mirror.appendChild(bee);
|
||||
alignY.appendChild(mirror);
|
||||
wrapper.appendChild(alignY);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function createLadybugGif(container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('spring-anim-wrapper');
|
||||
wrapper.classList.add('spring-ladybug-wrapper');
|
||||
|
||||
const alignY = document.createElement('div');
|
||||
alignY.classList.add('spring-align-y');
|
||||
|
||||
const mirror = document.createElement('div');
|
||||
mirror.classList.add('spring-mirror-wrapper');
|
||||
|
||||
const bug = document.createElement('img');
|
||||
bug.classList.add('spring-ladybug-gif');
|
||||
bug.src = ladybugImage;
|
||||
|
||||
const direction = Math.random() > 0.5 ? 'right' : 'left';
|
||||
const duration = Math.random() * 20 + 30;
|
||||
|
||||
if (direction === 'right') {
|
||||
wrapper.style.animation = `spring-walk-right ${duration}s linear forwards`;
|
||||
mirror.style.transform = 'scaleX(1)';
|
||||
} else {
|
||||
wrapper.style.animation = `spring-walk-left ${duration}s linear forwards`;
|
||||
mirror.style.transform = 'scaleX(-1)';
|
||||
}
|
||||
|
||||
wrapper.addEventListener('animationend', (e) => {
|
||||
if (e.animationName.includes('walk-')) {
|
||||
wrapper.remove();
|
||||
createLadybugGif(container);
|
||||
}
|
||||
});
|
||||
|
||||
bug.style.animation = `spring-crawl 2s ease-in-out infinite`;
|
||||
|
||||
// Target the Ladybug to walk on the ground visually (aligning properly with the CSS/SVG grass size)
|
||||
alignY.style.transform = `translateY(calc(100vh - 5px - 30px))`;
|
||||
|
||||
mirror.appendChild(bug);
|
||||
alignY.appendChild(mirror);
|
||||
wrapper.appendChild(alignY);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
const handleResize = debounce(() => {
|
||||
const container = document.querySelector('.spring-container');
|
||||
if (container) {
|
||||
createGrass(container);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
initializeSpring();
|
||||
BIN
Jellyfin.Plugin.Seasonals/Web/spring_assets/Bee.gif
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/spring_assets/Bird_1.gif
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/spring_assets/Bird_2.gif
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/spring_assets/Bird_3.gif
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/spring_assets/Butterfly_1.gif
Normal file
|
After Width: | Height: | Size: 502 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/spring_assets/Butterfly_2.gif
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/spring_assets/Rotkehlchen.gif
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/spring_assets/ladybug.gif
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/spring_assets/wasp.gif
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
39
Jellyfin.Plugin.Seasonals/Web/storm.css
Normal file
@@ -0,0 +1,39 @@
|
||||
.storm-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.raindrop {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background: linear-gradient(to bottom, rgba(200, 200, 255, 0), rgba(200, 200, 255, 0.7));
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
@keyframes stormy-rain {
|
||||
0% { transform: translateY(-30vh) translateX(0) rotate(20deg); opacity: 0; }
|
||||
5% { opacity: 1; }
|
||||
95% { opacity: 1; }
|
||||
100% { transform: translateY(110vh) translateX(-40vh) rotate(20deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.lightning-flash {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #fff;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
will-change: opacity;
|
||||
}
|
||||
99
Jellyfin.Plugin.Seasonals/Web/storm.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// 1. Read Configuration
|
||||
const config = window.SeasonalsPluginConfig?.Storm || {};
|
||||
|
||||
const enabled = config.EnableStorm !== undefined ? config.EnableStorm : true;
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const elementCount = isMobile ? (config.RaindropCountMobile || 150) : (config.RaindropCount || 300);
|
||||
const enableLightning = config.EnableLightning !== undefined ? config.EnableLightning : true;
|
||||
const rainSpeed = config.RainSpeed || 1.0;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
// 2. Toggle Function
|
||||
function toggleStorm() {
|
||||
const container = document.querySelector('.storm-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('Storm hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Storm visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. MutationObserver
|
||||
const observer = new MutationObserver(toggleStorm);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
// 4. Element Creation
|
||||
function createElements() {
|
||||
const container = document.querySelector('.storm-container') || document.createElement('div');
|
||||
|
||||
if (!document.querySelector('.storm-container')) {
|
||||
container.className = 'storm-container';
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
for (let i = 0; i < elementCount; i++) {
|
||||
const drop = document.createElement('div');
|
||||
drop.className = 'raindrop';
|
||||
|
||||
drop.style.left = `${Math.random() * 140}vw`;
|
||||
drop.style.top = `${-20 - Math.random() * 50}vh`;
|
||||
|
||||
const duration = (0.5 + Math.random() * 0.5) / (rainSpeed || 1);
|
||||
drop.style.animation = `stormy-rain ${duration}s linear infinite`;
|
||||
drop.style.animationDelay = `${Math.random() * 2}s`;
|
||||
drop.style.opacity = Math.random() * 0.5 + 0.3;
|
||||
|
||||
container.appendChild(drop);
|
||||
}
|
||||
|
||||
if (enableLightning) {
|
||||
const flash = document.createElement('div');
|
||||
flash.className = 'lightning-flash';
|
||||
container.appendChild(flash);
|
||||
|
||||
function triggerFlash() {
|
||||
const nextFlashDelay = 5000 + Math.random() * 10000;
|
||||
|
||||
setTimeout(() => {
|
||||
flash.style.opacity = '0.8';
|
||||
setTimeout(() => { flash.style.opacity = '0'; }, 50);
|
||||
setTimeout(() => { flash.style.opacity = '0.5'; }, 100);
|
||||
setTimeout(() => { flash.style.opacity = '0'; }, 150);
|
||||
|
||||
triggerFlash();
|
||||
}, nextFlashDelay);
|
||||
}
|
||||
|
||||
triggerFlash();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Initialization
|
||||
function initializeStorm() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
toggleStorm();
|
||||
}
|
||||
|
||||
initializeStorm();
|
||||
81
Jellyfin.Plugin.Seasonals/Web/summer.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.summer-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
147
Jellyfin.Plugin.Seasonals/Web/summer.js
Normal file
@@ -0,0 +1,147 @@
|
||||
const config = window.SeasonalsPluginConfig?.Summer || {};
|
||||
|
||||
const summer = config.EnableSummer !== undefined ? config.EnableSummer : true; // Enable/disable summer theme
|
||||
const bubbleCount = config.BubbleCount || 30; // Number of bubbles
|
||||
const dustCount = config.DustCount || 50; // Number of dust particles
|
||||
const randomSummer = config.EnableRandomSummer !== undefined ? config.EnableRandomSummer : true; // Enable random generating objects
|
||||
const randomSummerMobile = config.EnableRandomSummerMobile !== undefined ? config.EnableRandomSummerMobile : false; // Enable random generating objects on mobile
|
||||
const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; // Randomize animation duration of bubbles and dust
|
||||
|
||||
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) {
|
||||
// MARK: BUBBLE SIZE
|
||||
const size = Math.random() * 20 + 10; // 10-30px bubbles
|
||||
bubble.style.width = `${size}px`;
|
||||
bubble.style.height = `${size}px`;
|
||||
} else {
|
||||
// MARK: DUST SIZE
|
||||
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
|
||||
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) {
|
||||
// MARK: BUBBLE SIZE
|
||||
const size = Math.random() * 20 + 10;
|
||||
bubble.style.width = `${size}px`;
|
||||
bubble.style.height = `${size}px`;
|
||||
} else {
|
||||
// MARK: DUST SIZE
|
||||
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();
|
||||
@@ -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>
|
||||
522
Jellyfin.Plugin.Seasonals/Web/test-site.html
Normal file
@@ -0,0 +1,522 @@
|
||||
<!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="cherryblossom">Cherryblossom</option>
|
||||
<option value="earthday">Earth Day</option>
|
||||
<option value="eurovision">Eurovision</option>
|
||||
<option value="piday">Pi-Day</option>
|
||||
<option value="pride">Pride</option>
|
||||
<option value="rain">Rain</option>
|
||||
<option value="storm">Storm (Epilepsy Warning!)</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' },
|
||||
cherryblossom: { css: 'cherryblossom.css', js: 'cherryblossom.js', container: 'cherryblossom-container' },
|
||||
earthday: { css: 'earthday.css', js: 'earthday.js', container: 'earthday-container' },
|
||||
eurovision: { css: 'eurovision.css', js: 'eurovision.js', container: 'eurovision-container' },
|
||||
piday: { css: 'piday.css', js: 'piday.js', container: 'piday-container' },
|
||||
pride: { css: 'pride.css', js: 'pride.js', container: 'pride-container' },
|
||||
rain: { css: 'rain.css', js: 'rain.js', container: 'rain-container' },
|
||||
storm: { css: 'storm.css', js: 'storm.js', container: 'storm-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', '.cherryblossom-container',
|
||||
'.earthday-container', '.eurovision-container', '.piday-container',
|
||||
'.pride-container', '.rain-container', '.storm-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>
|
||||
189
README.md
@@ -13,20 +13,17 @@ This plugin is based on my manual mod (see the [legacy branch](https://github.co
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Features](#features)
|
||||
- [Overview](#overview)
|
||||
- [Ideas for additional seasonals](#ideas-for-additional-seasonals)
|
||||
- [Installation](#installation)
|
||||
- [Client Compatibility](#client-compatibility)
|
||||
- [Configuration](#configuration)
|
||||
- [Automatic Theme Selection](#automatic-theme-selection)
|
||||
- [Theme Settings](#theme-settings)
|
||||
- [Build The Plugin By Yourself](#build-the-plugin-by-yourself)
|
||||
- [Build the plugin by yourself](#build-the-plugin-by-yourself)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Effects Not Showing](#effects-not-showing)
|
||||
- [Docker Permission Issues](#docker-permission-issues)
|
||||
- [Contributing](#contributing)
|
||||
- [Legacy Manual Installation](#legacy-manual-installation)
|
||||
- [Installation](#installation-1)
|
||||
- [Usage](#usage)
|
||||
- [Additional Directory: Separate Single Seasonals](#additional-directory-separate-single-seasonals)
|
||||
|
||||
---
|
||||
|
||||
@@ -34,39 +31,83 @@ This plugin is based on my manual mod (see the [legacy branch](https://github.co
|
||||
|
||||
- **Automatic Theme Selection**: Dynamically updates the theme based on the date (e.g., snowflakes in December, fireworks for new year's eve).
|
||||
- **Easy Integration**: No manual file editing required. The plugin injects everything automatically.
|
||||
- **Configuration UI**: Configure settings directly in the Jellyfin Dashboard (very basic for now, needs some work in the future).
|
||||
- **Configuration UI**: Configure settings directly in the Jellyfin Dashboard.
|
||||
<details>
|
||||
<summary>Have a look:</summary>
|
||||
<img width="852" height="782" alt="Admin-Settings" src="https://github.com/user-attachments/assets/03d04ea8-7dd9-418e-88f8-9ae2937c06bb" />
|
||||
</details>
|
||||
- **User Toggle**: Optionally allow users to enable/disable seasonal effects from their client.
|
||||
<details>
|
||||
<summary>Have a look:</summary>
|
||||
<img width="467" height="263" alt="Client-Settings" src="https://github.com/user-attachments/assets/a8dfc90a-16c8-409c-9133-4139f6527b0b" />
|
||||
</details>
|
||||
|
||||
## Overview
|
||||
Click on the following themes to expand them and see the theme in action:
|
||||
|
||||
<details>
|
||||
<summary>Easter</summary>
|
||||
|
||||
**Easter**
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Autumn</summary>
|
||||
|
||||
**Autumn**
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Santa</summary>
|
||||
|
||||
**Santa**
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Christmas</summary>
|
||||
|
||||
**Christmas**
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Fireworks</summary>
|
||||
|
||||
**Fireworks**
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Halloween</summary>
|
||||
|
||||
**Halloween**
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Hearts</summary>
|
||||
|
||||
**Hearts**
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Snowfall</summary>
|
||||
|
||||
**Snowfall**
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Snowflakes</summary>
|
||||
|
||||
**Snowflakes**
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Snowstorm</summary>
|
||||
|
||||
**Snowstorm**
|
||||

|
||||
</details>
|
||||
|
||||
|
||||
## Ideas for additional seasonals
|
||||
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
|
||||
|
||||
@@ -135,7 +176,7 @@ If automatic selection is enabled, the following themes are applied based on the
|
||||
## Theme Settings
|
||||
Each theme contains additional settings to customize its behavior. Expand the advanced configuration section to configure each theme, adjust parameters like particle count, animation speed etc.
|
||||
|
||||
## Build The Plugin By Yourself
|
||||
## Build the plugin by yourself
|
||||
|
||||
If you want to build the plugin yourself:
|
||||
|
||||
@@ -202,117 +243,5 @@ volumes:
|
||||
## Contributing
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Legacy Manual Installation
|
||||
|
||||
<details>
|
||||
<summary>Click to expand instructions for the old manual installation method (without plugin)</summary>
|
||||
|
||||
### Installation
|
||||
|
||||
> [!TIP]
|
||||
> Take a look at [CodeDevMLH/Jellyfin-Mods-Automated-Script](https://github.com/CodeDevMLH/Jellyfin-Mods-Automated-Script)
|
||||
|
||||
1. **Add Seasonal Container to `index.html`**
|
||||
Edit the `index.html` file of your Jellyfin web instance. Add the following code inside the `<body>` tag:
|
||||
|
||||
```html
|
||||
<div class="seasonals-container"></div>
|
||||
<script src="seasonals/seasonals.js"></script>
|
||||
```
|
||||
2. **Deploy Files**
|
||||
Place the seasonals folder (including seasonals.js, CSS, and additional JavaScript files for each theme [this one](https://github.com/CodeDevMLH/Jellyfin-Seasonals/tree/legacy/seasonals)) inside the Jellyfin web server directory (labeld with "web").
|
||||
|
||||
3. **Configure Themes**
|
||||
Customize the theme-configs.js file to modify or add new themes. The default configuration is shown below:
|
||||
|
||||
```javascript
|
||||
const automateThemeSelection = true; // Set to false to disable automatic theme selection based on current date
|
||||
const defaultTheme = 'none'; // Default theme if automatic selection is off
|
||||
|
||||
const themeConfigs = {
|
||||
snowflakes: {
|
||||
css: 'seasonals/snowflakes.css',
|
||||
js: 'seasonals/snowflakes.js',
|
||||
containerClass: 'snowflakes'
|
||||
},
|
||||
snowfall: {
|
||||
css: 'seasonals/snowfall.css',
|
||||
js: 'seasonals/snowfall.js',
|
||||
containerClass: 'snowfall-container'
|
||||
},
|
||||
|
||||
// more configs...
|
||||
|
||||
none: {
|
||||
containerClass: 'none'
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
4. **Reload the web interface**
|
||||
After making these changes, restart your Jellyfin server and/or refresh the web interface (ctrl+F5, sometimes you need to clear the browsers temp files/cache (every time with firefox ;-()) to see the changes.
|
||||
|
||||
### Theme Settings
|
||||
Each theme's JavaScript file contains additional settings to customize its behavior. Here are examples for the `autumn` and `snowflakes` themes:
|
||||
|
||||
**Autumn Theme Settings**
|
||||
```javascript
|
||||
const leaves = true; // Enable/disable leaves
|
||||
const randomLeaves = true; // Enable random leaves
|
||||
const randomLeavesMobile = false; // Enable random leaves on mobile devices
|
||||
const enableDiffrentDuration = true; // Enable different animation duration for random leaves
|
||||
const leafCount = 25; // Number of random extra leaves
|
||||
```
|
||||
|
||||
**Snowflakes Theme Settings**
|
||||
```javascript
|
||||
const snowflakes = true; // Enable/disable snowflakes
|
||||
const randomSnowflakes = true; // Enable random snowflakes
|
||||
const randomSnowflakesMobile = false; // Enable random snowflakes on mobile devices
|
||||
const enableColoredSnowflakes = true; // Enable colored snowflakes on mobile devices
|
||||
const enableDiffrentDuration = true; // Enable different animation duration for random snowflakes
|
||||
const snowflakeCount = 25; // Number of random extra snowflakes
|
||||
```
|
||||
|
||||
### Usage
|
||||
**Automatic Theme Selection**
|
||||
By default, the theme is automatically selected based on the date. For example:
|
||||
|
||||
Snowfall: January
|
||||
Hearts: February (Valentine's Day)
|
||||
Halloween: October
|
||||
|
||||
Modify the determineCurrentTheme() function in seasonals.js to adjust date-based logic.
|
||||
|
||||
**Manual Theme Selection**
|
||||
To use a fixed theme, set automateThemeSelection to false in the theme-configs.js file and specify a defaultTheme.
|
||||
|
||||
**Custom Themes**
|
||||
1. Add your CSS and JavaScript files to the seasonals folder.
|
||||
|
||||
2. Extend the themeConfigs object with your theme details:
|
||||
```javascript
|
||||
myTheme: {
|
||||
css: 'seasonals/my-theme.css',
|
||||
js: 'seasonals/my-theme.js',
|
||||
containerClass: 'my-theme-container',
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Additional Directory: Separate Single Seasonals
|
||||
For users who prefer not to use the automatic seasonal theme selection, individual seasonals are available in the `separate single seasonals` folder. Each seasonal theme can be independently loaded and used without relying on the main automatic selection system.
|
||||
|
||||
but this requires to the modify of the `index.html` with adding the html in `add_to_index_html`.
|
||||
|
||||
To use a single seasonal theme, include its specific CSS and JS files in your `index.html` inside the `<body> </body>` tags provided by `add_to_index_html.html` in the sub-theme-folders as shown below:
|
||||
|
||||
```html
|
||||
<div class="seasonalsname-container"></div>
|
||||
<script src="separate single seasonals/snowflakes.js"></script>
|
||||
<link rel="stylesheet" href="separate single seasonals/snowflakes.css">
|
||||
|
||||
</details>
|
||||
|
||||
@@ -26,8 +26,11 @@ git merge main
|
||||
Setzt deine Commits neu auf die Spitze eines anderen Branches. Die Commit-IDs werden dabei neu geschrieben.
|
||||
|
||||
```bash
|
||||
git stash # (optional) Änderungen parken.
|
||||
git checkout dev
|
||||
git rebase main
|
||||
git fetch origin
|
||||
git rebase origin/main
|
||||
git stash pop # (optional) Änderungen zurückholen.
|
||||
```
|
||||
|
||||
| Details | |
|
||||
@@ -38,6 +41,60 @@ git rebase main
|
||||
|
||||
---
|
||||
|
||||
## **Git: dev zurücksetzen & „main ist Chef“**
|
||||
|
||||
Problem
|
||||
|
||||
Beim Arbeiten mit Git passiert oft Folgendes:
|
||||
|
||||
- `dev` hat Commits, die nicht in `main` sind
|
||||
- diese Commits brauche ich nicht mehr
|
||||
- beim `git rebase origin/main` will Git trotzdem mergen oder Konflikte lösen
|
||||
|
||||
⚠️ Wichtiges Missverständnis:
|
||||
|
||||
Das sind keine lokalen Änderungen, sondern Commits, die auf `dev` existieren.
|
||||
|
||||
`git reset --hard origin/dev` entfernt nur lokale, nicht gepushte Commits,
|
||||
aber nicht Commits, die bereits auf `origin/dev` liegen.
|
||||
|
||||
Praktische Befehle & sichere Vorgehensweise
|
||||
|
||||
- Prüfen — welche Commits würden entfernt werden:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git log --oneline origin/main..origin/dev
|
||||
```
|
||||
|
||||
- Optional: Backup des aktuellen `origin/dev`, falls etwas schiefgeht:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git branch backup/dev origin/dev
|
||||
```
|
||||
|
||||
- Sicheres Zurücksetzen von `dev` auf `main` (lokal + remote):
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
git push --force-with-lease origin dev
|
||||
```
|
||||
|
||||
- Alternative (ohne lokalen Checkout): Push von `main` direkt nach `dev`:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git push --force-with-lease origin origin/main:refs/heads/dev
|
||||
```
|
||||
|
||||
- Warnung: Diese Aktionen schreiben die Remote‑History um. Koordiniere dich mit
|
||||
dem Team bevor du ein Force‑Push ausführst. Verwende `--force-with-lease`
|
||||
anstelle von `--force` für etwas mehr Sicherheit.
|
||||
|
||||
|
||||
## 📦 Temporäres Speichern & Spezialbefehle
|
||||
|
||||
### Stash (Das "Regal")
|
||||
|
||||
170
manifest.json
@@ -9,12 +9,36 @@
|
||||
"imageUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/raw/branch/main/logo.png",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.6.2.0",
|
||||
"changelog": "- feat: Add client-side toggle option for seasonal settings",
|
||||
"version": "1.7.2.0",
|
||||
"changelog": "- feat: add Pi Day, Pride, Rain, and Storm themes\n- fix: improve performance",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/releases/download/v1.6.2.0/Jellyfin.Plugin.Seasonals.zip",
|
||||
"checksum": "22e876cf0a20118e14f8e2a2c35bdd50",
|
||||
"timestamp": "2026-02-03T18:18:34Z"
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/releases/download/v1.7.2.0/Jellyfin.Plugin.Seasonals.zip",
|
||||
"checksum": "34c8426c48bd7d470c3e8dc7f02f86da",
|
||||
"timestamp": "2026-02-23T00:34:13Z"
|
||||
},
|
||||
{
|
||||
"version": "1.7.1.5",
|
||||
"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.5/Jellyfin.Plugin.Seasonals.zip",
|
||||
"checksum": "f6447d476189e69fb96fe1675c55a1a0",
|
||||
"timestamp": "2026-02-21T14:28:30Z"
|
||||
},
|
||||
{
|
||||
"version": "1.7.0.15",
|
||||
"changelog": "- feat: add customizable auto seasonal list via config page\n- feat: add new season theme 'resurrection' by Bioflash257",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/releases/download/v1.7.0.15/Jellyfin.Plugin.Seasonals.zip",
|
||||
"checksum": "d1fc094710efe45ea8cc885bc6a826c4",
|
||||
"timestamp": "2026-02-17T13:11:21Z"
|
||||
},
|
||||
{
|
||||
"version": "1.6.3.0",
|
||||
"changelog": "- fix path issue on subpath installations",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Jellyfin-Seasonals-Plugin/releases/download/v1.6.3.0/Jellyfin.Plugin.Seasonals.zip",
|
||||
"checksum": "e9a1fb6c91b8b48978efb43c72e462a0",
|
||||
"timestamp": "2026-02-15T01:12:57Z"
|
||||
},
|
||||
{
|
||||
"version": "1.5.1.0",
|
||||
@@ -107,6 +131,142 @@
|
||||
"category": "General",
|
||||
"imageUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/raw/branch/main/logo.png",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.4.0.12",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.12/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "26edee51b52dcee4ecf388aa376f3869",
|
||||
"timestamp": "2026-02-04T18:07:40Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.11",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.11/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "ca0b3270eba5871e7a23db6b45bc5048",
|
||||
"timestamp": "2026-02-04T17:58:14Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.10",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.10/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "9618570053f7acef445a034c1e2e044b",
|
||||
"timestamp": "2026-02-04T17:51:25Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.9",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.9/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "29b3ffb9caeab135df88b6313032fc50",
|
||||
"timestamp": "2026-02-04T17:39:11Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.8",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.8/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "ec343204a7cd2c1af4013e645bdddcd3",
|
||||
"timestamp": "2026-02-04T17:27:22Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.7",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.7/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "450e5977228d08b8451b6047e4a6be94",
|
||||
"timestamp": "2026-02-04T17:09:28Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.6",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.6/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "348ebf449ac77fd156e2afbd03e80fce",
|
||||
"timestamp": "2026-02-04T16:40:21Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.5",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.5/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "3ba68bae1c492767bddab2dee2540226",
|
||||
"timestamp": "2026-02-04T16:23:14Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.4",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.4/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "30a15dd883de7656c4480cfa932e9858",
|
||||
"timestamp": "2026-02-04T16:17:15Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.3",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.3/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "9abf21c095e1ae99cdbeb51edb08f370",
|
||||
"timestamp": "2026-02-04T15:52:11Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.2",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.2/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "6026fb8878a51f6dbe18aab1ac006df8",
|
||||
"timestamp": "2026-02-04T15:45:39Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.1",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.1/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "4068c03b1ab809906d64d4faed1c1b0e",
|
||||
"timestamp": "2026-02-04T15:01:50Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.0",
|
||||
"changelog": "- feat: Add client-side settings feature for selected media bar settings",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.4.0.0/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "20faa2a703dbb46591f4bd09e6ab7ec3",
|
||||
"timestamp": "2026-02-04T12:49:45Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.0.3",
|
||||
"changelog": "- feat: Enhance custom media ID functionality with manual trailer override support",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.3.0.3/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "1d9e0a8342d46f84aed3f7bd1bee32d3",
|
||||
"timestamp": "2026-02-04T01:41:35Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.0.2",
|
||||
"changelog": "- feat: Enhance custom media ID functionality with manual trailer override support",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.3.0.2/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "22e79daa5f433ca09a3db4f8e37679b4",
|
||||
"timestamp": "2026-02-04T01:27:55Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.0.1",
|
||||
"changelog": "- feat: Enhance custom media ID functionality with manual trailer override support",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.3.0.1/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "5a4f555e29c733dabd51169f6ace56eb",
|
||||
"timestamp": "2026-02-04T01:14:19Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.0.0",
|
||||
"changelog": "- feat: Enhance custom media ID functionality with manual trailer override support",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.3.0.0/Jellyfin.Plugin.MediaBarEnhanced.zip",
|
||||
"checksum": "83c26ba8f7ad6e1a7fe73c7190f532f3",
|
||||
"timestamp": "2026-02-04T00:07:15Z"
|
||||
},
|
||||
{
|
||||
"version": "1.2.3.7",
|
||||
"changelog": "- Fixes the issue where buttons were cut off on smaller screens such as on S24/S25.\n- Update mediaBarEnhanced.js and mediaBarEnhanced.css with version 3.0.9 from original repo",
|
||||
|
||||