Compare commits
393 Commits
v1.3.0.0
...
c6d04b9b3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d04b9b3b | ||
|
|
1ceb9cef7f | ||
|
|
eb06a979f6 | ||
|
|
9b6d48a5fe | ||
|
|
e3ea4fa599 | ||
|
|
c5093073d0 | ||
|
|
85cabf29bb | ||
|
|
b008221cf4 | ||
|
|
2bbf13c044 | ||
|
|
082120b70b | ||
|
|
c66ccf970e | ||
|
|
861f431e50 | ||
|
|
be4313d776 | ||
|
|
9b8a563e43 | ||
|
|
8255683714 | ||
|
|
c24abcbd59 | ||
|
|
b17c2a6efe | ||
|
|
ad4fb7964b | ||
|
|
306b0c5e6e | ||
|
|
6cc344e0db | ||
|
|
3ea0709c77 | ||
|
|
b74c8ad2a1 | ||
|
|
8f0c2ac7df | ||
|
|
fa658c0057 | ||
|
|
de7e04c926 | ||
|
|
892be062d3 | ||
|
|
042d89f5b8 | ||
|
|
22709c38d1 | ||
|
|
22d40fb248 | ||
|
|
97dbc09daa | ||
|
|
df29e12699 | ||
|
|
6632cc81de | ||
|
|
437569ec1d | ||
|
|
5c0d8af5d8 | ||
|
|
5b98b442e5 | ||
|
|
e81ce3cab1 | ||
|
|
066ad6fc84 | ||
|
|
8baaa936e1 | ||
|
|
f9b4b3c25d | ||
|
|
f4f472e6ec | ||
|
|
e8effa7dfe | ||
|
|
ff2df0196a | ||
|
|
3e5da3dda2 | ||
|
|
509d198cd0 | ||
|
|
26eb40e282 | ||
|
|
08b2ae987e | ||
|
|
599518d627 | ||
|
|
23c5ab7e9d | ||
|
|
589a360729 | ||
|
|
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 | ||
|
|
0b4434c51c | ||
|
|
dd6583c055 | ||
|
|
a0b0514159 | ||
|
|
e977c83e8f | ||
|
|
e281f5c579 | ||
|
|
e6b646e478 | ||
|
|
8d6bc12fa4 | ||
|
|
f036e748da | ||
|
|
0177a7caea | ||
|
|
c45e9f6156 | ||
|
|
8dc9b9f157 | ||
|
|
e73c6c14bb | ||
|
|
aa1c60f9ce | ||
|
|
d1e668bcff | ||
|
|
0454d43f32 | ||
|
|
61b3b51139 | ||
|
|
60d1a546a2 | ||
|
|
669933d270 | ||
|
|
053f0ccfa7 | ||
|
|
60e998fc7f | ||
|
|
3aa631198a | ||
|
|
b1876d655e | ||
|
|
9a9f89c1fc | ||
|
|
2fb41f6442 | ||
|
|
4ab949e6d7 | ||
|
|
7d1024c917 | ||
|
|
c2e3d55110 | ||
|
|
c6503a90bb | ||
|
|
b70787d5ec | ||
|
|
a9eb8113a6 | ||
|
|
aaf15d8934 | ||
|
|
ec298ebde0 | ||
|
|
bb108d0f49 | ||
| f271e1715d | |||
|
|
bd0e2779e5 | ||
|
|
53a1682868 | ||
|
|
a7df2fd832 | ||
|
|
c56cde860b | ||
|
|
59211e27c6 | ||
|
|
a2b1179353 | ||
|
|
c7f34ec92f | ||
|
|
4c011cf560 | ||
|
|
e5f78c711d | ||
|
|
98a536315b | ||
|
|
01343848e3 | ||
|
|
113e7dd0f7 | ||
|
|
1bc4176771 | ||
|
|
b091e5592d | ||
|
|
cb2d86340e | ||
|
|
57a92e94de | ||
|
|
30bc8bef39 | ||
|
|
2d237063a3 | ||
|
|
049a5075b5 | ||
|
|
cb2e9c4b07 | ||
|
|
5a39f85082 | ||
|
|
b863d201b9 | ||
|
|
78b50b41c2 | ||
|
|
0ba1545fd6 | ||
|
|
16c4e0f29b | ||
|
|
3581b8cbb2 | ||
|
|
7184c93f6f | ||
|
|
0969f0238f | ||
|
|
12f868d3f9 | ||
|
|
dc7be56807 | ||
|
|
cb60690e6b | ||
|
|
e0397bb2e8 | ||
|
|
822bcafd11 | ||
|
|
429d96c816 | ||
|
|
e948055f0f | ||
|
|
5129d46163 | ||
|
|
660f7142ef | ||
|
|
9f657588d8 | ||
|
|
0457f1a764 | ||
|
|
f196c6c296 | ||
|
|
2d2dcaee71 | ||
|
|
399b19f384 | ||
|
|
cba5bc8c95 | ||
|
|
0c97e2f4c4 | ||
|
|
631fbfb1cb | ||
|
|
5f14caeeb7 | ||
|
|
bb4d35fc00 | ||
|
|
a30729cf0b | ||
|
|
83bc5f6b1d | ||
|
|
8bcfaee22e | ||
|
|
6882922c5b | ||
|
|
35ea057110 | ||
|
|
2656606f8c | ||
|
|
024343f045 | ||
|
|
887e697a90 | ||
|
|
6e623668d5 | ||
|
|
17c60d2fb3 | ||
|
|
b4ce6a7d18 | ||
|
|
d4ec7cf31d | ||
|
|
7f01da0301 | ||
|
|
0e19769d79 | ||
|
|
0cda35f636 | ||
|
|
f8e30efe66 | ||
|
|
4160ac59ca | ||
|
|
62ebe8b293 | ||
|
|
fd891e84c1 | ||
|
|
de211d4567 | ||
|
|
dc292504b0 | ||
|
|
9cf4da92ac | ||
|
|
817b8786c8 | ||
|
|
fe7f1a30bb | ||
| 2d72c6e957 | |||
| e8c4d5a044 | |||
| 781c605e12 | |||
|
|
c712777d61 | ||
| 9755763e36 | |||
| 3d24c84c0f | |||
| 73b64c7302 | |||
| f42bba5dc3 | |||
| 8095196b1e | |||
| 41ef12d970 | |||
| c984464f78 | |||
|
|
29af8b8671 | ||
|
|
2df173de8e | ||
|
|
6ea83e777a | ||
|
|
96d847a5f8 | ||
|
|
2e6a1c9322 | ||
|
|
90e028eb3b | ||
|
|
0db833c4dc | ||
|
|
8c00a35a22 | ||
|
|
8d6d202ee9 | ||
|
|
d9a4f623a0 | ||
|
|
6d32293127 | ||
|
|
7e33cb8fb7 | ||
|
|
15d6f98427 | ||
|
|
3b1d0982e5 | ||
|
|
737e510a6c | ||
|
|
fbd77e56fd | ||
|
|
63dadb4ffa | ||
|
|
7167472f10 | ||
| 21b0382cfd | |||
| 496d274a56 | |||
| 3cce9dd3f1 | |||
| 9beb14d6b6 | |||
|
|
b311fe4e2a | ||
|
|
70eac57e4d | ||
|
|
4e64e863e3 | ||
|
|
726d172625 | ||
|
|
1c6ed0aaed | ||
|
|
26eecc9ec4 | ||
|
|
912bf7f544 | ||
|
|
0e37d3a291 | ||
|
|
83a8c7fc74 | ||
|
|
b05b35b8f8 | ||
|
|
e90ea952bd | ||
|
|
7216588fef | ||
|
|
f4a7c3f2e5 | ||
|
|
176aa97c1c | ||
|
|
d73b1d17ff | ||
|
|
4bb37f89f3 | ||
|
|
f67e08ef89 | ||
|
|
94709b63fb | ||
|
|
988368b6f5 | ||
|
|
2216ed90c8 | ||
|
|
607042536c |
45
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
name: '🏗️ Build Plugin'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- '.gitea/**'
|
||||
- '.github/**'
|
||||
- 'jellyfin.ruleset'
|
||||
- '.gitignore'
|
||||
- '.editorconfig'
|
||||
- 'LICENSE'
|
||||
- 'logo.png'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: "9.x"
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
run: |
|
||||
dotnet build Jellyfin.Plugin.Seasonals/Jellyfin.Plugin.Seasonals.csproj --configuration Release --output bin/Publish
|
||||
cd bin/Publish
|
||||
zip -r Jellyfin.Plugin.Seasonals.zip *
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: plugin-build-artifact
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
path: bin/Publish/Jellyfin.Plugin.Seasonals.zip
|
||||
45
.gitea/workflows/build_old.yaml.deprecated
Normal file
@@ -0,0 +1,45 @@
|
||||
name: '🏗️ Build Plugin'
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- '.gitea/**'
|
||||
- '.github/**'
|
||||
- 'jellyfin.ruleset'
|
||||
- '.gitignore'
|
||||
- '.editorconfig'
|
||||
- 'LICENSE'
|
||||
- 'logo.png'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PIP_BREAK_SYSTEM_PACKAGES: "1"
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: "9.x"
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
uses: oddstr13/jellyfin-plugin-repository-manager@v1.1.1
|
||||
id: jprm
|
||||
with:
|
||||
dotnet-target: "net9.0"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: plugin-build-artifact
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
path: ${{ steps.jprm.outputs.artifact }}
|
||||
216
.gitea/workflows/release_automation.yml
Normal file
@@ -0,0 +1,216 @@
|
||||
name: Auto Release Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.gitea/**'
|
||||
- '.github/**'
|
||||
- '*.md'
|
||||
- 'jellyfin.ruleset'
|
||||
- '.gitignore'
|
||||
- '.editorconfig'
|
||||
- 'LICENSE'
|
||||
- 'logo.png'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: '9.x'
|
||||
|
||||
- name: Read Version from Manifest
|
||||
id: read_version
|
||||
run: |
|
||||
VERSION=$(jq -r '.[0].versions[0].version' manifest.json)
|
||||
CHANGELOG=$(jq -r '.[0].versions[0].changelog' manifest.json)
|
||||
TARGET_ABI=$(jq -r '.[0].versions[0].targetAbi' manifest.json)
|
||||
|
||||
echo "Detected Version: $VERSION"
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "TARGET_ABI=$TARGET_ABI" >> $GITHUB_ENV
|
||||
|
||||
# Also export GUID for later use
|
||||
PLUGIN_GUID=$(jq -r '.[0].guid' manifest.json)
|
||||
echo "PLUGIN_GUID=$PLUGIN_GUID" >> $GITHUB_ENV
|
||||
|
||||
# Escape newlines in changelog for GITHUB_ENV
|
||||
echo "CHANGELOG<<EOF" >> $GITHUB_ENV
|
||||
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
|
||||
dotnet build Jellyfin.Plugin.Seasonals/Jellyfin.Plugin.Seasonals.csproj --configuration Release --output bin/Publish /p:Version=${{ env.VERSION }} /p:AssemblyVersion=${{ env.VERSION }}
|
||||
|
||||
cd bin/Publish
|
||||
zip -r Jellyfin.Plugin.Seasonals.zip *
|
||||
cd ../..
|
||||
|
||||
# Calculate hash
|
||||
HASH=$(md5sum bin/Publish/Jellyfin.Plugin.Seasonals.zip | awk '{ print $1 }')
|
||||
TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Export variables for next steps
|
||||
echo "ZIP_HASH=$HASH" >> $GITHUB_ENV
|
||||
echo "BUILD_TIME=$TIME" >> $GITHUB_ENV
|
||||
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 }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
VERSION="${{ env.VERSION }}"
|
||||
DOWNLOAD_URL="https://git.mahom03-spacecloud.de/$REPO_OWNER/$REPO_NAME/releases/download/v$VERSION/Jellyfin.Plugin.Seasonals.zip"
|
||||
|
||||
echo "Updating manifest.json with:"
|
||||
echo "Hash: ${{ env.ZIP_HASH }}"
|
||||
echo "Time: ${{ env.BUILD_TIME }}"
|
||||
echo "Url: $DOWNLOAD_URL"
|
||||
|
||||
jq --arg hash "${{ env.ZIP_HASH }}" \
|
||||
--arg time "${{ env.BUILD_TIME }}" \
|
||||
--arg url "$DOWNLOAD_URL" \
|
||||
'.[0].versions[0].checksum = $hash | .[0].versions[0].timestamp = $time | .[0].versions[0].sourceUrl = $url' \
|
||||
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"
|
||||
body: ${{ env.CHANGELOG }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: ${{ env.ZIP_PATH }}
|
||||
name: "v${{ env.VERSION }}"
|
||||
tag_name: "v${{ env.VERSION }}"
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
# 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
|
||||
path: central-manifest
|
||||
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
|
||||
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
|
||||
# 1. Get info from previous steps
|
||||
VERSION="${{ env.VERSION }}"
|
||||
HASH="${{ env.ZIP_HASH }}"
|
||||
TIME="${{ env.BUILD_TIME }}"
|
||||
DOWNLOAD_URL="https://git.mahom03-spacecloud.de/$REPO_OWNER/$REPO_NAME/releases/download/v$VERSION/Jellyfin.Plugin.Seasonals.zip"
|
||||
|
||||
# 2. Get info from env
|
||||
PLUGIN_GUID="${{ env.PLUGIN_GUID }}"
|
||||
CHANGELOG="${{ env.CHANGELOG }}"
|
||||
TARGET_ABI="${{ env.TARGET_ABI }}"
|
||||
|
||||
echo "Updating Central Manifest for Plugin GUID: $PLUGIN_GUID"
|
||||
|
||||
# 3. Update/Prepend entry in central manifest.json
|
||||
# Logic:
|
||||
# - If array is empty or new version != old version: PREPEND new entry
|
||||
# - If new version == old version: OVERWRITE (update) existing entry (re-release)
|
||||
|
||||
jq --arg guid "$PLUGIN_GUID" \
|
||||
--arg hash "$HASH" \
|
||||
--arg time "$TIME" \
|
||||
--arg url "$DOWNLOAD_URL" \
|
||||
--arg ver "$VERSION" \
|
||||
--arg changelog "$CHANGELOG" \
|
||||
--arg abi "$TARGET_ABI" \
|
||||
'map(if .guid == $guid then
|
||||
if .versions[0].version != $ver then
|
||||
# New Version -> Prepend
|
||||
.versions = [{
|
||||
"version": $ver,
|
||||
"changelog": $changelog,
|
||||
"targetAbi": $abi,
|
||||
"sourceUrl": $url,
|
||||
"checksum": $hash,
|
||||
"timestamp": $time
|
||||
}] + .versions
|
||||
else
|
||||
# Same Version -> Update existing (overwrite top)
|
||||
.versions[0].changelog = $changelog |
|
||||
.versions[0].targetAbi = $abi |
|
||||
.versions[0].sourceUrl = $url |
|
||||
.versions[0].checksum = $hash |
|
||||
.versions[0].timestamp = $time
|
||||
end
|
||||
else . end)' \
|
||||
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"
|
||||
git config user.email "145071728+CodeDevMLH@users.noreply.github.com"
|
||||
|
||||
# Check if there are changes
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
git add manifest.json
|
||||
git commit -m "Auto-Update Seasonals to v${{ env.VERSION }}"
|
||||
git push
|
||||
else
|
||||
echo "No changes to central manifest."
|
||||
fi
|
||||
37
.github/workflows/build.yaml
vendored
@@ -3,16 +3,43 @@ name: '🏗️ Build Plugin'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- '.gitea/**'
|
||||
- '.github/**'
|
||||
- 'jellyfin.ruleset'
|
||||
- '.gitignore'
|
||||
- '.editorconfig'
|
||||
- 'LICENSE'
|
||||
- 'logo.png'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call:
|
||||
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: "9.x"
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
run: |
|
||||
dotnet build Jellyfin.Plugin.Seasonals/Jellyfin.Plugin.Seasonals.csproj --configuration Release --output bin/Publish
|
||||
cd bin/Publish
|
||||
zip -r Jellyfin.Plugin.Seasonals.zip *
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: plugin-build-artifact
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
path: bin/Publish/Jellyfin.Plugin.Seasonals.zip
|
||||
20
.github/workflows/changelog.yaml
vendored
@@ -1,20 +0,0 @@
|
||||
name: '📝 Create/Update Release Draft & Release Bump PR'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- build.yaml
|
||||
workflow_dispatch:
|
||||
repository_dispatch:
|
||||
types:
|
||||
- update-prep-command
|
||||
|
||||
jobs:
|
||||
call:
|
||||
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/changelog.yaml@master
|
||||
with:
|
||||
repository-name: jellyfin/jellyfin-plugin-template
|
||||
secrets:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
13
.github/workflows/command-dispatch.yaml
vendored
@@ -1,13 +0,0 @@
|
||||
# Allows for the definition of PR and Issue /commands
|
||||
name: '📟 Slash Command Dispatcher'
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
|
||||
jobs:
|
||||
call:
|
||||
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-dispatch.yaml@master
|
||||
secrets:
|
||||
token: .
|
||||
16
.github/workflows/command-rebase.yaml
vendored
@@ -1,16 +0,0 @@
|
||||
name: '🔀 PR Rebase Command'
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types:
|
||||
- rebase-command
|
||||
|
||||
jobs:
|
||||
call:
|
||||
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-rebase.yaml@master
|
||||
with:
|
||||
rebase-head: ${{ github.event.client_payload.pull_request.head.label }}
|
||||
repository-full-name: ${{ github.event.client_payload.github.payload.repository.full_name }}
|
||||
comment-id: ${{ github.event.client_payload.github.payload.comment.id }}
|
||||
secrets:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
18
.github/workflows/publish.yaml
vendored
@@ -1,18 +0,0 @@
|
||||
name: '🚀 Publish Plugin'
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call:
|
||||
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/publish.yaml@master
|
||||
with:
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
is-unstable: ${{ github.event.release.prerelease }}
|
||||
secrets:
|
||||
deploy-host: ${{ secrets.DEPLOY_HOST }}
|
||||
deploy-user: ${{ secrets.DEPLOY_USER }}
|
||||
deploy-key: ${{ secrets.DEPLOY_KEY }}
|
||||
184
.github/workflows/release_automation.yml
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
name: Auto Release Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- 'README.md'
|
||||
- 'jellyfin.ruleset'
|
||||
- '.gitignore'
|
||||
- '.editorconfig'
|
||||
- 'LICENSE'
|
||||
- 'logo.png'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: '9.x'
|
||||
|
||||
- name: Read Version from Manifest
|
||||
id: read_version
|
||||
run: |
|
||||
VERSION=$(jq -r '.[0].versions[0].version' manifest.json)
|
||||
CHANGELOG=$(jq -r '.[0].versions[0].changelog' manifest.json)
|
||||
TARGET_ABI=$(jq -r '.[0].versions[0].targetAbi' manifest.json)
|
||||
|
||||
echo "Detected Version: $VERSION"
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "TARGET_ABI=$TARGET_ABI" >> $GITHUB_ENV
|
||||
|
||||
# Also export GUID for later use
|
||||
PLUGIN_GUID=$(jq -r '.[0].guid' manifest.json)
|
||||
echo "PLUGIN_GUID=$PLUGIN_GUID" >> $GITHUB_ENV
|
||||
|
||||
# Escape newlines in changelog for GITHUB_ENV
|
||||
echo "CHANGELOG<<EOF" >> $GITHUB_ENV
|
||||
echo "$CHANGELOG" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and Zip
|
||||
shell: bash
|
||||
run: |
|
||||
# Inject version from manifest into the build
|
||||
dotnet build Jellyfin.Plugin.Seasonals/Jellyfin.Plugin.Seasonals.csproj --configuration Release --output bin/Publish /p:Version=${{ env.VERSION }} /p:AssemblyVersion=${{ env.VERSION }}
|
||||
|
||||
cd bin/Publish
|
||||
zip -r Jellyfin.Plugin.Seasonals.zip *
|
||||
cd ../..
|
||||
|
||||
# Calculate hash
|
||||
HASH=$(md5sum bin/Publish/Jellyfin.Plugin.Seasonals.zip | awk '{ print $1 }')
|
||||
TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Export variables for next steps
|
||||
echo "ZIP_HASH=$HASH" >> $GITHUB_ENV
|
||||
echo "BUILD_TIME=$TIME" >> $GITHUB_ENV
|
||||
echo "ZIP_PATH=bin/Publish/Jellyfin.Plugin.Seasonals.zip" >> $GITHUB_ENV
|
||||
|
||||
- name: Update manifest.json
|
||||
shell: bash
|
||||
run: |
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
VERSION="${{ env.VERSION }}"
|
||||
DOWNLOAD_URL="https://github.com/$REPO_OWNER/$REPO_NAME/releases/download/v$VERSION/Jellyfin.Plugin.Seasonals.zip"
|
||||
|
||||
echo "Updating manifest.json with:"
|
||||
echo "Hash: ${{ env.ZIP_HASH }}"
|
||||
echo "Time: ${{ env.BUILD_TIME }}"
|
||||
echo "Url: $DOWNLOAD_URL"
|
||||
|
||||
jq --arg hash "${{ env.ZIP_HASH }}" \
|
||||
--arg time "${{ env.BUILD_TIME }}" \
|
||||
--arg url "$DOWNLOAD_URL" \
|
||||
'.[0].versions[0].checksum = $hash | .[0].versions[0].timestamp = $time | .[0].versions[0].sourceUrl = $url' \
|
||||
manifest.json > manifest.json.tmp && mv manifest.json.tmp manifest.json
|
||||
|
||||
- name: Commit manifest.json
|
||||
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
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: "v${{ env.VERSION }}"
|
||||
name: "v${{ env.VERSION }}"
|
||||
# body: ${{ env.CHANGELOG }}
|
||||
files: ${{ env.ZIP_PATH }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
|
||||
# Update Message in Remote Repository
|
||||
- name: Checkout Central Manifest Repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ github.repository_owner }}/jellyfin-plugin-manifest
|
||||
path: central-manifest
|
||||
token: ${{ secrets.JELLYFIN_PLUGIN_MANIFEST_UPDATER_PAT }}
|
||||
|
||||
- name: Update Central Manifest
|
||||
shell: bash
|
||||
run: |
|
||||
cd central-manifest
|
||||
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
|
||||
# 1. Get info from previous steps
|
||||
VERSION="${{ env.VERSION }}"
|
||||
HASH="${{ env.ZIP_HASH }}"
|
||||
TIME="${{ env.BUILD_TIME }}"
|
||||
DOWNLOAD_URL="https://github.com/$REPO_OWNER/$REPO_NAME/releases/download/v$VERSION/Jellyfin.Plugin.Seasonals.zip"
|
||||
|
||||
# 2. Get info from env
|
||||
PLUGIN_GUID="${{ env.PLUGIN_GUID }}"
|
||||
CHANGELOG="${{ env.CHANGELOG }}"
|
||||
TARGET_ABI="${{ env.TARGET_ABI }}"
|
||||
|
||||
echo "Updating Central Manifest for Plugin GUID: $PLUGIN_GUID"
|
||||
|
||||
# 3. Update/Prepend entry in central manifest.json
|
||||
# Logic:
|
||||
# - If array is empty or new version != old version: PREPEND new entry
|
||||
# - If new version == old version: OVERWRITE (update) existing entry (re-release)
|
||||
|
||||
jq --arg guid "$PLUGIN_GUID" \
|
||||
--arg hash "$HASH" \
|
||||
--arg time "$TIME" \
|
||||
--arg url "$DOWNLOAD_URL" \
|
||||
--arg ver "$VERSION" \
|
||||
--arg changelog "$CHANGELOG" \
|
||||
--arg abi "$TARGET_ABI" \
|
||||
'map(if .guid == $guid then
|
||||
if .versions[0].version != $ver then
|
||||
# New Version -> Prepend
|
||||
.versions = [{
|
||||
"version": $ver,
|
||||
"changelog": $changelog,
|
||||
"targetAbi": $abi,
|
||||
"sourceUrl": $url,
|
||||
"checksum": $hash,
|
||||
"timestamp": $time
|
||||
}] + .versions
|
||||
else
|
||||
# Same Version -> Update existing (overwrite top)
|
||||
.versions[0].changelog = $changelog |
|
||||
.versions[0].targetAbi = $abi |
|
||||
.versions[0].sourceUrl = $url |
|
||||
.versions[0].checksum = $hash |
|
||||
.versions[0].timestamp = $time
|
||||
end
|
||||
else . end)' \
|
||||
manifest.json > manifest.json.tmp && mv manifest.json.tmp manifest.json
|
||||
|
||||
- name: Commit and Push Central Manifest
|
||||
run: |
|
||||
cd central-manifest
|
||||
git config user.name "CodeDevMLH"
|
||||
git config user.email "145071728+CodeDevMLH@users.noreply.github.com"
|
||||
|
||||
# Check if there are changes
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
git add manifest.json
|
||||
git commit -m "Auto-Update Seasonals to v${{ env.VERSION }}"
|
||||
git push
|
||||
else
|
||||
echo "No changes to central manifest."
|
||||
fi
|
||||
20
.github/workflows/scan-codeql.yaml
vendored
@@ -1,20 +0,0 @@
|
||||
name: '🔬 Run CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
schedule:
|
||||
- cron: '24 2 * * 4'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call:
|
||||
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/scan-codeql.yaml@master
|
||||
with:
|
||||
repository-name: jellyfin/jellyfin-plugin-template
|
||||
18
.github/workflows/test.yaml
vendored
@@ -1,18 +0,0 @@
|
||||
name: '🧪 Test Plugin'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call:
|
||||
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/test.yaml@master
|
||||
1
.gitignore
vendored
@@ -4,4 +4,5 @@ obj/
|
||||
.idea/
|
||||
artifacts
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Jellyfin.Plugin.Seasonals;
|
||||
using Jellyfin.Plugin.Seasonals.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.Seasonals.Api;
|
||||
|
||||
@@ -19,9 +21,9 @@ public class SeasonalsController : ControllerBase
|
||||
/// <returns>The configuration object.</returns>
|
||||
[HttpGet("Config")]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<object> GetConfig()
|
||||
public ActionResult<PluginConfiguration> GetConfig()
|
||||
{
|
||||
return Plugin.Instance?.Configuration ?? new object();
|
||||
return SeasonalsPlugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,7 +40,7 @@ public class SeasonalsController : ControllerBase
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var assembly = typeof(SeasonalsPlugin).Assembly;
|
||||
// Convert path to resource name
|
||||
var resourcePath = path.Replace('/', '.').Replace('\\', '.');
|
||||
var resourceName = $"Jellyfin.Plugin.Seasonals.Web.{resourcePath}";
|
||||
@@ -60,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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public PluginConfiguration()
|
||||
{
|
||||
IsEnabled = true;
|
||||
SelectedSeason = "none";
|
||||
AutomateSeasonSelection = true;
|
||||
EnableClientSideToggle = true;
|
||||
|
||||
Autumn = new AutumnOptions();
|
||||
Snowflakes = new SnowflakesOptions();
|
||||
@@ -25,8 +27,38 @@ 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();
|
||||
Frost = new FrostOptions();
|
||||
FilmNoir = new FilmNoirOptions();
|
||||
Oscar = new OscarOptions();
|
||||
MarioDay = new MarioDayOptions();
|
||||
StarWars = new StarWarsOptions();
|
||||
Oktoberfest = new OktoberfestOptions();
|
||||
Friday13 = new Friday13Options();
|
||||
Eid = new EidOptions();
|
||||
Spooky = new SpookyOptions();
|
||||
Sports = new SportsOptions();
|
||||
Olympia = new OlympiaOptions();
|
||||
Space = new SpaceOptions();
|
||||
Underwater = new UnderwaterOptions();
|
||||
Birthday = new BirthdayOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the plugin is enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selected season.
|
||||
/// </summary>
|
||||
@@ -37,6 +69,19 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public bool AutomateSeasonSelection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable client-side toggle for users.
|
||||
/// </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>
|
||||
public AutumnOptions Autumn { get; set; }
|
||||
public SnowflakesOptions Snowflakes { get; set; }
|
||||
public SnowfallOptions Snowfall { get; set; }
|
||||
@@ -47,6 +92,31 @@ 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 FrostOptions Frost { get; set; }
|
||||
public FilmNoirOptions FilmNoir { get; set; }
|
||||
public OscarOptions Oscar { get; set; }
|
||||
public MarioDayOptions MarioDay { get; set; }
|
||||
public StarWarsOptions StarWars { get; set; }
|
||||
public OktoberfestOptions Oktoberfest { get; set; }
|
||||
public Friday13Options Friday13 { get; set; }
|
||||
public EidOptions Eid { get; set; }
|
||||
public SpookyOptions Spooky { get; set; }
|
||||
public SportsOptions Sports { get; set; }
|
||||
public OlympiaOptions Olympia { get; set; }
|
||||
public SpaceOptions Space { get; set; }
|
||||
public UnderwaterOptions Underwater { get; set; }
|
||||
public BirthdayOptions Birthday { get; set; }
|
||||
}
|
||||
|
||||
public class AutumnOptions
|
||||
@@ -104,6 +174,8 @@ public class HalloweenOptions
|
||||
public bool EnableRandomSymbols { get; set; } = true;
|
||||
public bool EnableRandomSymbolsMobile { get; set; } = false;
|
||||
public bool EnableDifferentDuration { get; set; } = true;
|
||||
public bool EnableSpiders { get; set; } = true;
|
||||
public bool EnableMice { get; set; } = true;
|
||||
}
|
||||
|
||||
public class HeartsOptions
|
||||
@@ -151,3 +223,203 @@ 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 bool EnablePiDayBackground { get; set; } = false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public class FrostOptions
|
||||
{
|
||||
public bool EnableFrost { get; set; } = true;
|
||||
}
|
||||
|
||||
public class FilmNoirOptions
|
||||
{
|
||||
public bool EnableFilmNoir { get; set; } = true;
|
||||
}
|
||||
|
||||
public class OscarOptions
|
||||
{
|
||||
public bool EnableOscar { get; set; } = true;
|
||||
}
|
||||
|
||||
public class MarioDayOptions
|
||||
{
|
||||
public bool EnableMarioDay { get; set; } = true;
|
||||
}
|
||||
|
||||
public class StarWarsOptions
|
||||
{
|
||||
public bool EnableStarWars { get; set; } = true;
|
||||
}
|
||||
|
||||
public class OktoberfestOptions
|
||||
{
|
||||
public bool EnableOktoberfest { get; set; } = true;
|
||||
}
|
||||
|
||||
public class Friday13Options
|
||||
{
|
||||
public bool EnableFriday13 { get; set; } = true;
|
||||
}
|
||||
|
||||
public class EidOptions
|
||||
{
|
||||
public bool EnableEid { get; set; } = true;
|
||||
}
|
||||
|
||||
public class SpookyOptions
|
||||
{
|
||||
public bool EnableSpooky { get; set; } = true;
|
||||
public int SymbolCount { get; set; } = 25;
|
||||
public bool EnableSpookySway { get; set; } = true;
|
||||
public int SpookySize { get; set; } = 20;
|
||||
public int SpookyGlowSize { get; set; } = 2;
|
||||
}
|
||||
|
||||
public class SportsOptions
|
||||
{
|
||||
public int SymbolCount { get; set; } = 25;
|
||||
public bool EnableSports { get; set; } = true;
|
||||
public bool EnableRandomSymbols { get; set; } = true;
|
||||
public bool EnableRandomSymbolsMobile { get; set; } = false;
|
||||
public bool EnableDifferentDuration { get; set; } = true;
|
||||
}
|
||||
|
||||
public class OlympiaOptions
|
||||
{
|
||||
public int SymbolCount { get; set; } = 25;
|
||||
public bool EnableOlympia { get; set; } = true;
|
||||
public bool EnableRandomSymbols { get; set; } = true;
|
||||
public bool EnableRandomSymbolsMobile { get; set; } = false;
|
||||
public bool EnableDifferentDuration { get; set; } = true;
|
||||
}
|
||||
|
||||
public class SpaceOptions
|
||||
{
|
||||
public int SymbolCount { get; set; } = 25;
|
||||
public bool EnableSpace { get; set; } = true;
|
||||
public bool EnableRandomSymbols { get; set; } = true;
|
||||
public bool EnableRandomSymbolsMobile { get; set; } = false;
|
||||
public bool EnableDifferentDuration { get; set; } = true;
|
||||
}
|
||||
|
||||
public class UnderwaterOptions
|
||||
{
|
||||
public int SymbolCount { get; set; } = 15;
|
||||
public bool EnableUnderwater { get; set; } = true;
|
||||
public bool EnableRandomSymbols { get; set; } = true;
|
||||
public bool EnableRandomSymbolsMobile { get; set; } = false;
|
||||
public bool EnableDifferentDuration { get; set; } = true;
|
||||
}
|
||||
|
||||
public class BirthdayOptions
|
||||
{
|
||||
public int SymbolCount { get; set; } = 25;
|
||||
public bool EnableBirthday { get; set; } = true;
|
||||
public bool EnableRandomSymbols { get; set; } = true;
|
||||
public bool EnableRandomSymbolsMobile { get; set; } = false;
|
||||
public bool EnableDifferentDuration { get; set; } = true;
|
||||
}
|
||||
|
||||
49
Jellyfin.Plugin.Seasonals/Helpers/TransformationPatches.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Jellyfin.Plugin.Seasonals.Model;
|
||||
|
||||
namespace Jellyfin.Plugin.Seasonals.Helpers
|
||||
{
|
||||
public static class TransformationPatches
|
||||
{
|
||||
public static string IndexHtml(PatchRequestPayload payload)
|
||||
{
|
||||
// Always return original content if something fails or is null
|
||||
string? originalContents = payload?.Contents;
|
||||
|
||||
if (string.IsNullOrEmpty(originalContents))
|
||||
{
|
||||
return originalContents ?? string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
// Safety Check: If plugin is disabled, do nothing
|
||||
if (SeasonalsPlugin.Instance?.Configuration.IsEnabled != true)
|
||||
{
|
||||
return originalContents;
|
||||
}
|
||||
|
||||
// Use StringBuilder for efficient modification
|
||||
var builder = new System.Text.StringBuilder(originalContents);
|
||||
|
||||
// Inject Script if missing
|
||||
if (!originalContents.Contains("seasonals.js", StringComparison.Ordinal))
|
||||
{
|
||||
var scriptIndex = originalContents.LastIndexOf(ScriptInjector.Marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (scriptIndex != -1)
|
||||
{
|
||||
builder.Insert(scriptIndex, ScriptInjector.ScriptTag + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// On error, return original content to avoid breaking the UI
|
||||
return originalContents;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
<!-- <TreatWarningsAsErrors>false</TreatWarningsAsErrors> -->
|
||||
<Title>Jellyfin Seasonals Plugin</Title>
|
||||
<Authors>CodeDevMLH</Authors>
|
||||
<Version>1.2.0.0</Version>
|
||||
<RepositoryUrl>https://github.com/CodeDevMLH/jellyfin-plugin-seasonals</RepositoryUrl>
|
||||
<Version>1.7.2.0</Version>
|
||||
<RepositoryUrl>https://github.com/CodeDevMLH/Jellyfin-Seasonals</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -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" />
|
||||
|
||||
10
Jellyfin.Plugin.Seasonals/Model/PatchRequestPayload.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Seasonals.Model
|
||||
{
|
||||
public class PatchRequestPayload
|
||||
{
|
||||
[JsonPropertyName("contents")]
|
||||
public string? Contents { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using Jellyfin.Plugin.Seasonals.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Jellyfin.Plugin.Seasonals;
|
||||
|
||||
/// <summary>
|
||||
/// The main plugin.
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
private readonly ScriptInjector _scriptInjector;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILoggerFactory loggerFactory)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
_scriptInjector = new ScriptInjector(applicationPaths, loggerFactory.CreateLogger<ScriptInjector>());
|
||||
if (!_scriptInjector.Inject())
|
||||
{
|
||||
TryRegisterFallback(loggerFactory.CreateLogger("FileTransformationFallback"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Seasonals";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("ef1e863f-cbb0-4e47-9f23-f0cbb1826ad4");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current plugin instance.
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback method for FileTransformation plugin.
|
||||
/// </summary>
|
||||
/// <param name="payload">The payload containing the file contents.</param>
|
||||
/// <returns>The modified file contents.</returns>
|
||||
public static string TransformIndexHtml(JObject payload)
|
||||
{
|
||||
// CRITICAL: Always return original content if something fails or is null
|
||||
string? originalContents = payload?["contents"]?.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(originalContents))
|
||||
{
|
||||
return originalContents ?? string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var html = originalContents;
|
||||
const string inject = "<script src=\"/Seasonals/Resources/seasonals.js\"></script>\n<body";
|
||||
|
||||
if (!html.Contains("seasonals.js", StringComparison.Ordinal))
|
||||
{
|
||||
return html.Replace("<body", inject, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// On error, return original content to avoid breaking the UI
|
||||
return originalContents;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryRegisterFallback(ILogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Find the FileTransformation assembly across all load contexts
|
||||
var assembly = AssemblyLoadContext.All
|
||||
.SelectMany(x => x.Assemblies)
|
||||
.FirstOrDefault(x => x.FullName?.Contains(".FileTransformation") ?? false);
|
||||
|
||||
if (assembly == null)
|
||||
{
|
||||
logger.LogWarning("FileTransformation plugin not found. Fallback injection skipped.");
|
||||
return;
|
||||
}
|
||||
|
||||
var type = assembly.GetType("Jellyfin.Plugin.FileTransformation.PluginInterface");
|
||||
if (type == null)
|
||||
{
|
||||
logger.LogWarning("Jellyfin.Plugin.FileTransformation.PluginInterface not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var method = type.GetMethod("RegisterTransformation");
|
||||
if (method == null)
|
||||
{
|
||||
logger.LogWarning("RegisterTransformation method not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create JObject payload directly using Newtonsoft.Json
|
||||
var payload = new JObject
|
||||
{
|
||||
{ "id", Id.ToString() },
|
||||
{ "fileNamePattern", "index.html" },
|
||||
{ "callbackAssembly", this.GetType().Assembly.FullName },
|
||||
{ "callbackClass", this.GetType().FullName },
|
||||
{ "callbackMethod", nameof(TransformIndexHtml) }
|
||||
};
|
||||
|
||||
// Invoke RegisterTransformation with the JObject payload
|
||||
method.Invoke(null, new object[] { payload });
|
||||
logger.LogInformation("Successfully registered fallback transformation via FileTransformation plugin.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error attempting to register fallback transformation.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
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;
|
||||
|
||||
@@ -13,8 +18,8 @@ public class ScriptInjector
|
||||
{
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
private readonly ILogger<ScriptInjector> _logger;
|
||||
private const string ScriptTag = "<script src=\"/Seasonals/Resources/seasonals.js\"></script>";
|
||||
private const string Marker = "</body>";
|
||||
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.
|
||||
@@ -30,53 +35,63 @@ public class ScriptInjector
|
||||
/// <summary>
|
||||
/// Injects the script tag into index.html if it's not already present.
|
||||
/// </summary>
|
||||
/// <returns>True if injection was successful or already present, false otherwise.</returns>
|
||||
public bool Inject()
|
||||
public void Inject()
|
||||
{
|
||||
try
|
||||
{
|
||||
var webPath = GetWebPath();
|
||||
if (string.IsNullOrEmpty(webPath))
|
||||
{
|
||||
_logger.LogWarning("Could not find Jellyfin web path. Script injection skipped.");
|
||||
return false;
|
||||
_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.", indexPath);
|
||||
return false;
|
||||
_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, StringComparison.Ordinal))
|
||||
|
||||
|
||||
// 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("Seasonals script already injected.");
|
||||
return true;
|
||||
_logger.LogInformation("Removed legacy tags from index.html.");
|
||||
}
|
||||
|
||||
// Insert before the closing body tag
|
||||
var newContent = content.Replace(Marker, $"{ScriptTag}\n{Marker}", StringComparison.Ordinal);
|
||||
if (string.Equals(newContent, content, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("Could not find closing body tag in index.html. Script injection skipped.");
|
||||
return false;
|
||||
}
|
||||
|
||||
File.WriteAllText(indexPath, newContent);
|
||||
_logger.LogInformation("Successfully injected Seasonals script into index.html.");
|
||||
return true;
|
||||
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("Permission denied when attempting to inject script into index.html. Automatic injection failed. Please ensure the Jellyfin web directory is writable by the process, or manually add the script tag: {ScriptTag}", ScriptTag);
|
||||
return false;
|
||||
_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.");
|
||||
return false;
|
||||
_logger.LogError(ex, "Error injecting Seasonals script. Attempting fallback.");
|
||||
RegisterFileTransformation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +100,8 @@ public class ScriptInjector
|
||||
/// </summary>
|
||||
public void Remove()
|
||||
{
|
||||
UnregisterFileTransformation();
|
||||
|
||||
try
|
||||
{
|
||||
var webPath = GetWebPath();
|
||||
@@ -100,21 +117,29 @@ public class ScriptInjector
|
||||
}
|
||||
|
||||
var content = File.ReadAllText(indexPath);
|
||||
if (!content.Contains(ScriptTag, StringComparison.Ordinal))
|
||||
if (content.Contains(ScriptTag))
|
||||
{
|
||||
return;
|
||||
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.");
|
||||
}
|
||||
|
||||
// Try to remove with newline first, then just the tag to ensure clean removal
|
||||
var newContent = content.Replace($"{ScriptTag}\n", "", StringComparison.Ordinal)
|
||||
.Replace(ScriptTag, "", StringComparison.Ordinal);
|
||||
|
||||
File.WriteAllText(indexPath, newContent);
|
||||
_logger.LogInformation("Successfully removed Seasonals script from index.html.");
|
||||
// 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)
|
||||
{
|
||||
_logger.LogWarning("Permission denied when attempting to remove script from index.html.");
|
||||
_logger.LogWarning("Unauthorized access when attempting to remove script from index.html.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -132,4 +157,91 @@ public class ScriptInjector
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
89
Jellyfin.Plugin.Seasonals/SeasonalsPlugin.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.Seasonals.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Seasonals;
|
||||
|
||||
/// <summary>
|
||||
/// The main plugin.
|
||||
/// </summary>
|
||||
public class SeasonalsPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
private readonly ScriptInjector _scriptInjector;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
|
||||
public SeasonalsPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILoggerFactory loggerFactory)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
_loggerFactory = loggerFactory;
|
||||
_scriptInjector = new ScriptInjector(applicationPaths, loggerFactory.CreateLogger<ScriptInjector>());
|
||||
|
||||
if (Configuration.IsEnabled)
|
||||
{
|
||||
_scriptInjector.Inject();
|
||||
}
|
||||
else
|
||||
{
|
||||
_scriptInjector.Remove();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void UpdateConfiguration(BasePluginConfiguration configuration)
|
||||
{
|
||||
var oldConfig = Configuration;
|
||||
base.UpdateConfiguration(configuration);
|
||||
|
||||
if (Configuration.IsEnabled != oldConfig.IsEnabled)
|
||||
{
|
||||
if (Configuration.IsEnabled)
|
||||
{
|
||||
_scriptInjector.Inject();
|
||||
}
|
||||
else
|
||||
{
|
||||
_scriptInjector.Remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Seasonals";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("ef1e863f-cbb0-4e47-9f23-f0cbb1826ad4");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current plugin instance.
|
||||
/// </summary>
|
||||
public static SeasonalsPlugin? Instance { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EnableInMainMenu = true,
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
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 |
@@ -1,13 +1,22 @@
|
||||
.autumn-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.leaf {
|
||||
position: fixed;
|
||||
top: -10%;
|
||||
z-index: 15;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
translate: 0 -10vh;
|
||||
font-size: 1em;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -34,30 +43,30 @@
|
||||
|
||||
@-webkit-keyframes leaf-fall {
|
||||
0% {
|
||||
top: -10%;
|
||||
translate: 0 -10vh;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
translate: 0 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes leaf-fall {
|
||||
0% {
|
||||
top: -10%;
|
||||
translate: 0 -10vh;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
translate: 0 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes leaf-shake {
|
||||
0%, 100% {
|
||||
-webkit-transform: translateX(0) rotate(var(--rotate-start, -20deg));
|
||||
transform: translateX(0) rotate(var(--rotate-start, -20deg));
|
||||
}
|
||||
50% {
|
||||
-webkit-transform: translateX(80px) rotate(var(--rotate-end, 20deg));
|
||||
transform: translateX(80px) rotate(var(--rotate-end, 20deg));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
const config = window.SeasonalsPluginConfig?.autumn || {};
|
||||
const config = window.SeasonalsPluginConfig?.Autumn || {};
|
||||
|
||||
const leaves = config.enableAutumn !== undefined ? config.enableAutumn : true; // enable/disable leaves
|
||||
const randomLeaves = config.enableRandomLeaves !== undefined ? config.enableRandomLeaves : true; // enable random leaves
|
||||
const randomLeavesMobile = config.enableRandomLeavesMobile !== undefined ? config.enableRandomLeavesMobile : false; // enable random leaves on mobile devices (Warning: High values may affect performance)
|
||||
const enableDiffrentDuration = config.enableDifferentDuration !== undefined ? config.enableDifferentDuration : true; // enable different duration for the random leaves
|
||||
const enableRotation = config.enableRotation !== undefined ? config.enableRotation : false; // enable/disable leaf rotation
|
||||
const leafCount = config.leafCount || 25; // count of random extra leaves
|
||||
const leaves = config.EnableAutumn !== undefined ? config.EnableAutumn : true; // enable/disable leaves
|
||||
const randomLeaves = config.EnableRandomLeaves !== undefined ? config.EnableRandomLeaves : true; // enable random leaves
|
||||
const randomLeavesMobile = config.EnableRandomLeavesMobile !== undefined ? config.EnableRandomLeavesMobile : false; // enable random leaves on mobile devices (Warning: High values may affect performance)
|
||||
const enableDiffrentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; // enable different duration for the random leaves
|
||||
const enableRotation = config.EnableRotation !== undefined ? config.EnableRotation : false; // enable/disable leaf rotation
|
||||
const leafCount = config.LeafCount || 25; // count of random extra leaves
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
let msgPrinted = false; // flag to prevent multiple console messages
|
||||
|
||||
@@ -38,35 +57,13 @@ function toggleAutumn() {
|
||||
|
||||
// observe changes in the DOM
|
||||
const observer = new MutationObserver(toggleAutumn);
|
||||
|
||||
// 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)
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
function addRandomLeaves(count) {
|
||||
const autumnContainer = document.querySelector('.autumn-container'); // get the leave container
|
||||
if (!autumnContainer) return; // exit if leave container is not found
|
||||
@@ -90,7 +87,7 @@ function addRandomLeaves(count) {
|
||||
// set random horizontal position, animation delay and size(uncomment lines to enable)
|
||||
const randomLeft = Math.random() * 100; // position (0% to 100%)
|
||||
const randomAnimationDelay = Math.random() * 12; // delay for fall (0s to 12s)
|
||||
const randomAnimationDelay2 = Math.random() * 4; // delay for shake+rotate (0s to 4s)
|
||||
const randomAnimationDelay2 = -(Math.random() * 4); // delay for shake+rotate (-4s to 0s)
|
||||
|
||||
// apply styles
|
||||
leaveDiv.style.left = `${randomLeft}%`;
|
||||
|
||||
90
Jellyfin.Plugin.Seasonals/Web/birthday.css
Normal file
@@ -0,0 +1,90 @@
|
||||
.birthday-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.birthday-cake {
|
||||
position: absolute;
|
||||
bottom: 2vh;
|
||||
left: 50vw;
|
||||
transform: translateX(-50%);
|
||||
font-size: 8rem;
|
||||
z-index: 50;
|
||||
filter: drop-shadow(0 0 10px rgba(255,255,255,0.4));
|
||||
}
|
||||
|
||||
.birthday-cake img {
|
||||
height: 15vh;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.birthday-symbol {
|
||||
position: absolute;
|
||||
bottom: -10vh; /* balloons rise from bottom */
|
||||
animation: birthday-rise linear infinite;
|
||||
font-size: 3rem;
|
||||
opacity: 0.95;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.birthday-symbol img {
|
||||
width: 6vh;
|
||||
height: auto;
|
||||
max-width: 60px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.birthday-confetti {
|
||||
position: absolute;
|
||||
top: -5vh;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
opacity: 0.9;
|
||||
animation: birthday-confetti-fall linear infinite;
|
||||
z-index: 30;
|
||||
/* Mix of circles and squares by using CSS variables or random in JS. For simplicity, we make all slightly rounded rectangles */
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@keyframes birthday-rise {
|
||||
0% {
|
||||
transform: translateY(10vh) rotate(var(--start-rot, 0deg));
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-110vh) rotate(calc(var(--start-rot, 0deg) * -1));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes birthday-confetti-fall {
|
||||
0% {
|
||||
transform: translateY(-5vh) rotateX(0deg) rotateY(0deg) rotateZ(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
5% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(105vh) rotateX(720deg) rotateY(360deg) rotateZ(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
145
Jellyfin.Plugin.Seasonals/Web/birthday.js
Normal file
@@ -0,0 +1,145 @@
|
||||
const config = window.SeasonalsPluginConfig?.Birthday || {};
|
||||
|
||||
const birthday = config.EnableBirthday !== undefined ? config.EnableBirthday : true;
|
||||
const symbolCount = config.SymbolCount || 25;
|
||||
const useRandomSymbols = config.EnableRandomSymbols !== undefined ? config.EnableRandomSymbols : true;
|
||||
const enableRandomMobile = config.EnableRandomSymbolsMobile !== undefined ? config.EnableRandomSymbolsMobile : false;
|
||||
const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
function toggleBirthday() {
|
||||
const container = document.querySelector('.birthday-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('Birthday hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Birthday visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleBirthday);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
function createBirthday() {
|
||||
const container = document.querySelector('.birthday-container') || document.createElement('div');
|
||||
|
||||
if (!document.querySelector('.birthday-container')) {
|
||||
container.className = 'birthday-container';
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// Spawn Birthday Cake at the bottom
|
||||
const cake = document.createElement('div');
|
||||
cake.className = 'birthday-cake';
|
||||
let cakeImg = document.createElement('img');
|
||||
cakeImg.src = `../Seasonals/Resources/birthday_images/cake.png`;
|
||||
cakeImg.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
this.parentElement.innerHTML = '🎂';
|
||||
};
|
||||
cake.appendChild(cakeImg);
|
||||
container.appendChild(cake);
|
||||
|
||||
const standardCount = 15;
|
||||
const totalSymbols = symbolCount + standardCount;
|
||||
|
||||
let isMobile = window.matchMedia("only screen and (max-width: 768px)").matches;
|
||||
let finalCount = totalSymbols;
|
||||
|
||||
if (isMobile) {
|
||||
finalCount = enableRandomMobile ? totalSymbols : standardCount;
|
||||
}
|
||||
|
||||
const useRandomDuration = enableDifferentDuration !== false;
|
||||
|
||||
// We'll treat balloons and gifts as rising symbols
|
||||
const activeItems = ['balloon_red', 'balloon_blue', 'balloon_yellow', 'gift'];
|
||||
|
||||
for (let i = 0; i < finalCount; i++) {
|
||||
let symbol = document.createElement('div');
|
||||
|
||||
const randomItem = activeItems[Math.floor(Math.random() * activeItems.length)];
|
||||
symbol.className = `birthday-symbol birthday-${randomItem}`;
|
||||
|
||||
let img = document.createElement('img');
|
||||
img.src = `../Seasonals/Resources/birthday_images/${randomItem}.png`;
|
||||
img.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
this.parentElement.innerHTML = getBirthdayEmojiFallback(randomItem);
|
||||
};
|
||||
symbol.appendChild(img);
|
||||
|
||||
const leftPos = Math.random() * 95;
|
||||
const delaySeconds = Math.random() * 10;
|
||||
|
||||
let durationSeconds = 9;
|
||||
if (useRandomDuration) {
|
||||
durationSeconds = Math.random() * 5 + 7; // 7 to 12 seconds
|
||||
}
|
||||
|
||||
const startRot = (Math.random() * 20) - 10; // -10 to +10 spread
|
||||
symbol.style.setProperty('--start-rot', `${startRot}deg`);
|
||||
|
||||
symbol.style.left = `${leftPos}vw`;
|
||||
symbol.style.animationDuration = `${durationSeconds}s`;
|
||||
symbol.style.animationDelay = `${delaySeconds}s`;
|
||||
|
||||
container.appendChild(symbol);
|
||||
}
|
||||
|
||||
// Party Confetti
|
||||
const confettiColors = ['#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#fabebe', '#008080', '#e6beff', '#9a6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#808080', '#ffffff', '#000000'];
|
||||
const confettiCount = isMobile ? 40 : 80;
|
||||
|
||||
for (let i = 0; i < confettiCount; i++) {
|
||||
let confetti = document.createElement('div');
|
||||
confetti.className = 'birthday-confetti';
|
||||
|
||||
const color = confettiColors[Math.floor(Math.random() * confettiColors.length)];
|
||||
confetti.style.backgroundColor = color;
|
||||
|
||||
const leftPos = Math.random() * 100;
|
||||
const delaySeconds = Math.random() * 8;
|
||||
const duration = Math.random() * 3 + 4;
|
||||
|
||||
confetti.style.left = `${leftPos}vw`;
|
||||
confetti.style.animationDuration = `${duration}s`;
|
||||
confetti.style.animationDelay = `${delaySeconds}s`;
|
||||
|
||||
container.appendChild(confetti);
|
||||
}
|
||||
}
|
||||
|
||||
function getBirthdayEmojiFallback(type) {
|
||||
if (type.startsWith('balloon')) return '🎈';
|
||||
if (type === 'gift') return '🎁';
|
||||
return '';
|
||||
}
|
||||
|
||||
function initializeBirthday() {
|
||||
if (!birthday) return;
|
||||
createBirthday();
|
||||
toggleBirthday();
|
||||
}
|
||||
|
||||
initializeBirthday();
|
||||
BIN
Jellyfin.Plugin.Seasonals/Web/birthday_assets/balloon_blue.gif
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/birthday_assets/balloon_green.gif
Normal file
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 240 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/birthday_assets/balloon_orange.gif
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/birthday_assets/balloon_pink.gif
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/birthday_assets/balloon_red.gif
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/birthday_assets/balloon_yellow.gif
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/birthday_assets/gift_1.gif
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/birthday_assets/gift_2.gif
Normal file
|
After Width: | Height: | Size: 108 KiB |
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: 0;
|
||||
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: rgb(0, 0, 0);
|
||||
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: translate3d(0, -10vh, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(0, 110vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
191
Jellyfin.Plugin.Seasonals/Web/carnival.js
Normal file
@@ -0,0 +1,191 @@
|
||||
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
|
||||
|
||||
const confettiColors = [
|
||||
'#fce18a', '#ff726d', '#b48def', '#f4306d',
|
||||
'#36c5f0', '#2ccc5d', '#e9b31d', '#9b59b6',
|
||||
'#3498db', '#e74c3c', '#1abc9c', '#f1c40f'
|
||||
];
|
||||
|
||||
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);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
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}%`;
|
||||
|
||||
// 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`;
|
||||
}
|
||||
|
||||
// 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`;
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
initCarnivalObjects();
|
||||
toggleCarnival();
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
if (randomCarnival && (screenWidth > 768 || randomCarnivalMobile)) {
|
||||
addRandomCarnivalObjects(carnivalCount);
|
||||
}
|
||||
}
|
||||
|
||||
initializeCarnival();
|
||||
60
Jellyfin.Plugin.Seasonals/Web/cherryblossom.css
Normal file
@@ -0,0 +1,60 @@
|
||||
.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: 0;
|
||||
z-index: 1005;
|
||||
width: 15px;
|
||||
height: 10px;
|
||||
background-color: #ffc0cb;
|
||||
border-radius: 15px 0px 15px 0px;
|
||||
|
||||
will-change: transform;
|
||||
translate: 0 -10vh;
|
||||
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% { translate: 0 -10vh; }
|
||||
100% { translate: 0 110vh; }
|
||||
}
|
||||
|
||||
@keyframes cherryblossom-sway {
|
||||
0%, 100% {
|
||||
transform: translateX(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(30px) rotate(45deg);
|
||||
}
|
||||
}
|
||||
105
Jellyfin.Plugin.Seasonals/Web/cherryblossom.js
Normal file
@@ -0,0 +1,105 @@
|
||||
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();
|
||||
@@ -1,13 +1,22 @@
|
||||
.christmas-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.christmas {
|
||||
position: fixed;
|
||||
top: -10%;
|
||||
z-index: 15;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
translate: 0 -10vh;
|
||||
font-size: 1em;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -27,11 +36,11 @@
|
||||
|
||||
@-webkit-keyframes christmas-fall {
|
||||
0% {
|
||||
top: -10%;
|
||||
translate: 0 -10vh;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
translate: 0 110vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,28 +48,25 @@
|
||||
|
||||
0%,
|
||||
100% {
|
||||
-webkit-transform: translateX(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translateX(80px);
|
||||
transform: translateX(80px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes christmas-fall {
|
||||
0% {
|
||||
top: -10%;
|
||||
translate: 0 -10vh;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
translate: 0 110vh;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes christmas-shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
const config = window.SeasonalsPluginConfig?.christmas || {};
|
||||
const config = window.SeasonalsPluginConfig?.Christmas || {};
|
||||
|
||||
const christmas = config.enableChristmas !== undefined ? config.enableChristmas : true; // enable/disable christmas
|
||||
const randomChristmas = config.enableRandomChristmas !== undefined ? config.enableRandomChristmas : true; // enable random Christmas
|
||||
const randomChristmasMobile = config.enableRandomChristmasMobile !== undefined ? config.enableRandomChristmasMobile : false; // enable random Christmas on mobile devices (Warning: High values may affect performance)
|
||||
const enableDiffrentDuration = config.enableDifferentDuration !== undefined ? config.enableDifferentDuration : true; // enable different duration for the random Christmas symbols
|
||||
const christmasCount = config.symbolCount || 25; // count of random extra christmas
|
||||
const christmas = config.EnableChristmas !== undefined ? config.EnableChristmas : true; // enable/disable christmas
|
||||
const randomChristmas = config.EnableRandomChristmas !== undefined ? config.EnableRandomChristmas : true; // enable random Christmas
|
||||
const randomChristmasMobile = config.EnableRandomChristmasMobile !== undefined ? config.EnableRandomChristmasMobile : false; // enable random Christmas on mobile devices (Warning: High values may affect performance)
|
||||
const enableDiffrentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; // enable different duration for the random Christmas symbols
|
||||
const christmasCount = config.SymbolCount || 25; // count of random extra christmas
|
||||
|
||||
// Array of christmas characters
|
||||
const christmasSymbols = ['❆', '🎁', '❄️', '🎁', '🎅', '🎊', '🎁', '🎉'];
|
||||
|
||||
let msgPrinted = false; // flag to prevent multiple console messages
|
||||
|
||||
@@ -37,17 +39,12 @@ function toggleChristmas() {
|
||||
|
||||
// observe changes in the DOM
|
||||
const observer = new MutationObserver(toggleChristmas);
|
||||
|
||||
// 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)
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
// Array of christmas characters
|
||||
const christmasSymbols = ['❆', '🎁', '❄️', '🎁', '🎅', '🎊', '🎁', '🎉'];
|
||||
|
||||
function addRandomChristmas(count) {
|
||||
const christmasContainer = document.querySelector('.christmas-container'); // get the christmas container
|
||||
if (!christmasContainer) return; // exit if christmas container is not found
|
||||
|
||||
35
Jellyfin.Plugin.Seasonals/Web/earthday.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.earthday-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 8vh;
|
||||
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); }
|
||||
}
|
||||
127
Jellyfin.Plugin.Seasonals/Web/earthday.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const config = window.SeasonalsPluginConfig?.EarthDay || {};
|
||||
|
||||
const enabled = config.EnableEarthDay !== undefined ? config.EnableEarthDay : true;
|
||||
const vineCount = config.VineCount || 4;
|
||||
|
||||
const flowerColors = ['#FF69B4', '#FFD700', '#87CEFA', '#FF4500', '#BA55D3', '#FFA500', '#FF1493'];
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleEarthDay);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
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;
|
||||
// MARK: GRASS HEIGHT CONFIGURATION
|
||||
// To prevent squishing, hSVG calculation MUST match the height in earthday.css exactly
|
||||
// earthday.css uses 8vh, so here it is 0.08
|
||||
const hSVG = Math.floor(window.innerHeight * 0.08) || 80;
|
||||
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() * 15 - 7.5); // curvature
|
||||
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 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 = flowerColors[Math.floor(Math.random() * flowerColors.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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
function initializeEarthDay() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
toggleEarthDay();
|
||||
}
|
||||
|
||||
initializeEarthDay();
|
||||
@@ -1,152 +1,63 @@
|
||||
.easter-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
z-index: 10000;
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.easter-grass-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 8vh;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.easter-meadow-layer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.easter-meadow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* sway */
|
||||
.easter-sway {
|
||||
transform-origin: bottom center;
|
||||
animation: easter-wind-sway 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes easter-wind-sway {
|
||||
0% { transform: skewX(-3deg); }
|
||||
100% { transform: skewX(5deg); }
|
||||
}
|
||||
|
||||
.hopping-rabbit {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
width: 70px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
bottom: -15px;
|
||||
left: 0;
|
||||
width: 160px;
|
||||
height: auto;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hopping-rabbit {
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.easter {
|
||||
position: fixed;
|
||||
top: -10%;
|
||||
font-size: 1em;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
text-shadow: 0 0 5px #000;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
-webkit-animation-name: easter-fall, easter-shake;
|
||||
-webkit-animation-timing-function: linear, ease-in-out;
|
||||
-webkit-animation-iteration-count: infinite, infinite;
|
||||
animation-name: easter-fall, easter-shake;
|
||||
animation-timing-function: linear, ease-in-out;
|
||||
animation-iteration-count: infinite, infinite;
|
||||
}
|
||||
|
||||
.easter img {
|
||||
height: auto;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
@-webkit-keyframes easter-fall {
|
||||
0% {
|
||||
top: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes easter-shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
-webkit-transform: translateX(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
-webkit-transform: translateX(80px);
|
||||
transform: translateX(80px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes easter-fall {
|
||||
0% {
|
||||
top: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes easter-shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(80px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.easter:nth-of-type(0) {
|
||||
left: 0%;
|
||||
animation-delay: 0s, 0s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(1) {
|
||||
left: 10%;
|
||||
animation-delay: 1s, 1s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(2) {
|
||||
left: 20%;
|
||||
animation-delay: 6s, 0.5s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(3) {
|
||||
left: 30%;
|
||||
animation-delay: 4s, 2s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(4) {
|
||||
left: 40%;
|
||||
animation-delay: 2s, 2s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(5) {
|
||||
left: 50%;
|
||||
animation-delay: 8s, 3s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(6) {
|
||||
left: 60%;
|
||||
animation-delay: 6s, 2s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(7) {
|
||||
left: 70%;
|
||||
animation-delay: 2.5s, 1s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(8) {
|
||||
left: 80%;
|
||||
animation-delay: 1s, 0s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(9) {
|
||||
left: 90%;
|
||||
animation-delay: 3s, 1.5s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(10) {
|
||||
left: 25%;
|
||||
animation-delay: 2s, 0s;
|
||||
}
|
||||
|
||||
.easter:nth-of-type(11) {
|
||||
left: 65%;
|
||||
animation-delay: 4s, 2.5s;
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
const config = window.SeasonalsPluginConfig?.easter || {};
|
||||
const config = window.SeasonalsPluginConfig?.Easter || {};
|
||||
|
||||
const easter = config.enableEaster !== undefined ? config.enableEaster : true; // enable/disable easter
|
||||
const randomEaster = config.enableRandomEaster !== undefined ? config.enableRandomEaster : true; // enable random easter
|
||||
const randomEasterMobile = config.enableRandomEasterMobile !== undefined ? config.enableRandomEasterMobile : false; // enable random easter on mobile devices (Warning: High values may affect performance)
|
||||
const enableDiffrentDuration = config.enableDifferentDuration !== undefined ? config.enableDifferentDuration : true; // enable different duration for the random easter
|
||||
const easterEggCount = config.eggCount || 20; // count of random extra easter
|
||||
const easter = config.EnableEaster !== undefined ? config.EnableEaster : true;
|
||||
const enableBunny = config.EnableBunny !== undefined ? config.EnableBunny : true;
|
||||
/* MARK: Bunny movement config */
|
||||
const jumpDistanceVw = 5; // Distance in vw the bunny covers per jump
|
||||
const jumpDurationMs = 770; // Time in ms the bunny spends moving during a jump
|
||||
const pauseDurationMs = 116.6666; // Time in ms the bunny pauses between jumps
|
||||
|
||||
const bunny = config.enableBunny !== undefined ? config.enableBunny : true; // enable/disable hopping bunny
|
||||
const bunnyDuration = config.bunnyDuration || 12000; // duration of the bunny animation in ms
|
||||
const hopHeight = config.hopHeight || 12; // height of the bunny hops in px
|
||||
const minBunnyRestTime = config.minBunnyRestTime || 2000; // minimum time the bunny rests in ms
|
||||
const maxBunnyRestTime = config.maxBunnyRestTime || 5000; // maximum time the bunny rests in ms
|
||||
const minBunnyRestTime = config.MinBunnyRestTime || 2000;
|
||||
const maxBunnyRestTime = config.MaxBunnyRestTime || 5000;
|
||||
const eggCount = config.EggCount || 15;
|
||||
|
||||
const rabbit = "../Seasonals/Resources/easter_images/Osterhase.gif";
|
||||
|
||||
let msgPrinted = false; // flag to prevent multiple console messages
|
||||
let animationFrameId;
|
||||
const easterEggImages = [
|
||||
"../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/eggs.png"
|
||||
];
|
||||
|
||||
// function to check and control the easter
|
||||
let msgPrinted = false;
|
||||
|
||||
// Check visibility
|
||||
function toggleEaster() {
|
||||
const easterContainer = document.querySelector('.easter-container');
|
||||
if (!easterContainer) return;
|
||||
@@ -26,21 +41,20 @@ function toggleEaster() {
|
||||
const isDashboard = document.body.classList.contains('dashboardDocument');
|
||||
const hasUserMenu = document.querySelector('#app-user-menu');
|
||||
|
||||
// hide easter if video/trailer player is active or dashboard is visible
|
||||
if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) {
|
||||
easterContainer.style.display = 'none'; // hide easter
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
easterContainer.style.display = 'none';
|
||||
if (rabbitTimeout) {
|
||||
clearTimeout(rabbitTimeout);
|
||||
isAnimating = false;
|
||||
}
|
||||
if (!msgPrinted) {
|
||||
console.log('Easter hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
easterContainer.style.display = 'block'; // show easter
|
||||
if (!animationFrameId) {
|
||||
animateRabbit(); // start animation
|
||||
easterContainer.style.display = 'block';
|
||||
if (!isAnimating && enableBunny) {
|
||||
animateRabbit(document.querySelector('#rabbit'));
|
||||
}
|
||||
if (msgPrinted) {
|
||||
console.log('Easter visible');
|
||||
@@ -49,145 +63,201 @@ function toggleEaster() {
|
||||
}
|
||||
}
|
||||
|
||||
// observe changes in the DOM
|
||||
const observer = new MutationObserver(toggleEaster);
|
||||
|
||||
// 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)
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
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",
|
||||
];
|
||||
const rabbit = "Seasonals/Resources/easter_images/easter-bunny.png";
|
||||
|
||||
function addRandomEaster(count) {
|
||||
const easterContainer = document.querySelector('.easter-container'); // get the leave container
|
||||
if (!easterContainer) return; // exit if leave container is not found
|
||||
|
||||
console.log('Adding random easter eggs');
|
||||
|
||||
// Array of leave characters
|
||||
for (let i = 0; i < count; i++) {
|
||||
// create a new leave element
|
||||
const eggDiv = document.createElement('div');
|
||||
eggDiv.className = "easter";
|
||||
|
||||
// pick a random easter symbol
|
||||
const imageSrc = images[Math.floor(Math.random() * images.length)];
|
||||
const img = document.createElement("img");
|
||||
img.src = imageSrc;
|
||||
|
||||
eggDiv.appendChild(img);
|
||||
|
||||
// set random horizontal position, animation delay and size(uncomment lines to enable)
|
||||
const randomLeft = Math.random() * 100; // position (0% to 100%)
|
||||
const randomAnimationDelay = Math.random() * 12; // delay (0s to 12s)
|
||||
const randomAnimationDelay2 = Math.random() * 5; // delay (0s to 5s)
|
||||
|
||||
// apply styles
|
||||
eggDiv.style.left = `${randomLeft}%`;
|
||||
eggDiv.style.animationDelay = `${randomAnimationDelay}s, ${randomAnimationDelay2}s`;
|
||||
|
||||
// set random animation duration
|
||||
if (enableDiffrentDuration) {
|
||||
const randomAnimationDuration = Math.random() * 10 + 6; // delay (6s to 10s)
|
||||
const randomAnimationDuration2 = Math.random() * 5 + 2; // delay (2s to 5s)
|
||||
eggDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`;
|
||||
}
|
||||
|
||||
|
||||
// add the leave to the container
|
||||
easterContainer.appendChild(eggDiv);
|
||||
function createEasterGrassAndEggs(container) {
|
||||
let grassContainer = container.querySelector('.easter-grass-container');
|
||||
if (!grassContainer) {
|
||||
grassContainer = document.createElement('div');
|
||||
grassContainer.className = 'easter-grass-container';
|
||||
container.appendChild(grassContainer);
|
||||
}
|
||||
|
||||
grassContainer.innerHTML = '';
|
||||
|
||||
let pathsBg = '';
|
||||
let pathsFg = '';
|
||||
const w = window.innerWidth;
|
||||
const hSVG = 80; // Grass 80px high
|
||||
|
||||
// Generate Grass
|
||||
const bladeCount = w / 5;
|
||||
for (let i = 0; i < bladeCount; i++) {
|
||||
const height = Math.random() * 40 + 20;
|
||||
const x = i * 5 + Math.random() * 3;
|
||||
const hue = 80 + Math.random() * 40; // slightly more yellow-green for spring/easter
|
||||
const color = `hsl(${hue}, 60%, 40%)`;
|
||||
const line = `<line x1="${x}" y1="${hSVG}" x2="${x}" y2="${hSVG - height}" stroke="${color}" stroke-width="2" />`;
|
||||
if (Math.random() > 0.33) pathsBg += line; else pathsFg += line;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 200; i++) {
|
||||
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' : '#8bc34a';
|
||||
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"/>`;
|
||||
if (Math.random() > 0.33) pathsBg += path; else pathsFg += path;
|
||||
}
|
||||
|
||||
// Generate Flowers
|
||||
const colors = ['#FF69B4', '#FFD700', '#87CEFA', '#FF4500', '#BA55D3', '#FFA500', '#FF1493'];
|
||||
for (let i = 0; i < 40; 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)];
|
||||
|
||||
let path = '';
|
||||
path += `<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;
|
||||
path += `<circle cx="${x-r}" cy="${y-r}" r="${r}" fill="${col}"/>`;
|
||||
path += `<circle cx="${x+r}" cy="${y-r}" r="${r}" fill="${col}"/>`;
|
||||
path += `<circle cx="${x-r}" cy="${y+r}" r="${r}" fill="${col}"/>`;
|
||||
path += `<circle cx="${x+r}" cy="${y+r}" r="${r}" fill="${col}"/>`;
|
||||
path += `<circle cx="${x}" cy="${y}" r="${r*0.7}" fill="#FFF8DC"/>`;
|
||||
|
||||
if (Math.random() > 0.33) pathsBg += path; else pathsFg += path;
|
||||
}
|
||||
|
||||
grassContainer.innerHTML = `
|
||||
<div class="easter-meadow-layer" style="z-index: 1001;">
|
||||
<svg class="easter-meadow" viewBox="0 0 ${w} ${hSVG}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="easter-sway">
|
||||
${pathsBg}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="easter-meadow-layer" style="z-index: 1003;">
|
||||
<svg class="easter-meadow" viewBox="0 0 ${w} ${hSVG}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="easter-sway" style="animation-delay: -2s;">
|
||||
${pathsFg}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add Easter Eggs
|
||||
for (let i = 0; i < eggCount; i++) {
|
||||
const x = 2 + Math.random() * 96;
|
||||
const y = Math.random() * 18; // 0 to 18px off bottom
|
||||
const imageSrc = easterEggImages[Math.floor(Math.random() * easterEggImages.length)];
|
||||
|
||||
const eggImg = document.createElement('img');
|
||||
eggImg.src = imageSrc;
|
||||
eggImg.style.position = 'absolute';
|
||||
eggImg.style.left = `${x}vw`;
|
||||
eggImg.style.bottom = `${y}px`;
|
||||
eggImg.style.width = `${15 + Math.random() * 10}px`;
|
||||
eggImg.style.height = 'auto';
|
||||
eggImg.style.transform = `rotate(${Math.random() * 60 - 30}deg)`;
|
||||
eggImg.style.zIndex = Math.random() > 0.5 ? '1000' : '1004'; // Between grass layers
|
||||
|
||||
grassContainer.appendChild(eggImg);
|
||||
}
|
||||
console.log('Random easter added');
|
||||
}
|
||||
|
||||
function addHoppingRabbit() {
|
||||
if (!bunny) return; // Nur ausführen, wenn Easter aktiviert ist
|
||||
let rabbitTimeout;
|
||||
let isAnimating = false;
|
||||
|
||||
const easterContainer = document.querySelector('.easter-container');
|
||||
if (!easterContainer) return;
|
||||
function addHoppingRabbit(container) {
|
||||
if (!enableBunny) return;
|
||||
|
||||
// Hase erstellen
|
||||
const rabbitImg = document.createElement("img");
|
||||
rabbitImg.id = "rabbit";
|
||||
rabbitImg.src = rabbit; // Bildpfad aus der bestehenden Definition
|
||||
rabbitImg.alt = "Hoppelnder Osterhase";
|
||||
rabbitImg.src = rabbit;
|
||||
rabbitImg.alt = "Hopping Easter Bunny";
|
||||
rabbitImg.className = "hopping-rabbit";
|
||||
|
||||
rabbitImg.style.bottom = "-15px";
|
||||
rabbitImg.style.position = "absolute";
|
||||
|
||||
// CSS-Klassen hinzufügen
|
||||
rabbitImg.classList.add("hopping-rabbit");
|
||||
|
||||
easterContainer.appendChild(rabbitImg);
|
||||
|
||||
rabbitImg.style.bottom = (hopHeight / 2 + 6) + "px";
|
||||
container.appendChild(rabbitImg);
|
||||
|
||||
animateRabbit(rabbitImg);
|
||||
}
|
||||
|
||||
function animateRabbit(rabbitElement) {
|
||||
const rabbit = rabbitElement || document.querySelector('#rabbit');
|
||||
if (!rabbit) return;
|
||||
function animateRabbit(rabbit) {
|
||||
if (!rabbit || isAnimating) return;
|
||||
isAnimating = true;
|
||||
|
||||
const startFromLeft = Math.random() >= 0.5;
|
||||
const startX = startFromLeft ? -15 : 115;
|
||||
let currentX = startX;
|
||||
const endX = startFromLeft ? 115 : -15;
|
||||
const direction = startFromLeft ? 1 : -1;
|
||||
|
||||
rabbit.style.transition = 'none';
|
||||
const transformScale = startFromLeft ? 'scaleX(-1)' : '';
|
||||
rabbit.style.transform = `translateX(${currentX}vw) ${transformScale}`;
|
||||
|
||||
const loopDurationMs = jumpDurationMs + pauseDurationMs;
|
||||
|
||||
let startTime = null;
|
||||
|
||||
function animationStep(timestamp) {
|
||||
if (!document.querySelector('.easter-container') || rabbit.style.display === 'none') {
|
||||
isAnimating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startTime) {
|
||||
startTime = timestamp;
|
||||
|
||||
// random start position and direction
|
||||
const startFromLeft = Math.random() >= 0.5;
|
||||
rabbit.startX = startFromLeft ? -10 : 110;
|
||||
rabbit.endX = startFromLeft ? 110 : -10;
|
||||
rabbit.direction = startFromLeft ? 1 : -1;
|
||||
|
||||
// mirror the rabbit image if it starts from the right
|
||||
rabbit.style.transform = startFromLeft ? '' : 'scaleX(-1)';
|
||||
// resetting gif, forces the browser to restart the GIF from the first frame (crucial for syncing hops with movement)
|
||||
const currSrc = rabbit.src.split('?')[0];
|
||||
rabbit.src = '';
|
||||
rabbit.src = currSrc;
|
||||
}
|
||||
const progress = timestamp - startTime;
|
||||
|
||||
// calculate the horizontal position (linear interpolation)
|
||||
const x = rabbit.startX + (progress / bunnyDuration) * (rabbit.endX - rabbit.startX);
|
||||
const elapsed = timestamp - startTime;
|
||||
|
||||
const completedLoops = Math.floor(elapsed / loopDurationMs);
|
||||
const timeInCurrentLoop = elapsed % loopDurationMs;
|
||||
|
||||
// calculate the vertical position (sinus curve)
|
||||
const y = Math.sin((progress / 500) * Math.PI) * hopHeight; // 500ms for one hop
|
||||
|
||||
// set the new position
|
||||
rabbit.style.transform = `translate(${x}vw, ${y}px) scaleX(${rabbit.direction})`;
|
||||
|
||||
if (progress < bunnyDuration) {
|
||||
animationFrameId = requestAnimationFrame(animationStep);
|
||||
// Determine if we are currently jumping or pausing
|
||||
let currentLoopDistance = 0;
|
||||
if (timeInCurrentLoop < jumpDurationMs) {
|
||||
// We are in the jumping phase
|
||||
currentLoopDistance = (timeInCurrentLoop / jumpDurationMs) * jumpDistanceVw;
|
||||
} else {
|
||||
// let the bunny rest for a while before hiding easter eggs again
|
||||
const pauseDuration = Math.random() * (maxBunnyRestTime - minBunnyRestTime) + minBunnyRestTime;
|
||||
setTimeout(() => {
|
||||
startTime = null;
|
||||
animationFrameId = requestAnimationFrame(animationStep);
|
||||
}, pauseDuration);
|
||||
// We are in the paused phase
|
||||
currentLoopDistance = jumpDistanceVw;
|
||||
}
|
||||
|
||||
currentX = startX + (completedLoops * jumpDistanceVw + currentLoopDistance) * direction;
|
||||
|
||||
// Update DOM without CSS transitions
|
||||
rabbit.style.transform = `translateX(${currentX}vw) ${transformScale}`;
|
||||
|
||||
// Check if finished crossing
|
||||
if ((direction === 1 && currentX >= endX) || (direction === -1 && currentX <= endX)) {
|
||||
let restTime = Math.random() * (maxBunnyRestTime - minBunnyRestTime) + minBunnyRestTime;
|
||||
|
||||
isAnimating = false;
|
||||
rabbitTimeout = setTimeout(() => {
|
||||
animateRabbit(document.querySelector('#rabbit'));
|
||||
}, restTime);
|
||||
return;
|
||||
}
|
||||
|
||||
rabbitTimeout = requestAnimationFrame(animationStep);
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(animationStep);
|
||||
// Start loop
|
||||
rabbitTimeout = requestAnimationFrame(animationStep);
|
||||
}
|
||||
|
||||
function initializeEaster() {
|
||||
if (!easter) return;
|
||||
|
||||
// initialize standard easter
|
||||
function initEaster() {
|
||||
const container = document.querySelector('.easter-container') || document.createElement("div");
|
||||
|
||||
if (!document.querySelector('.easter-container')) {
|
||||
@@ -196,48 +266,17 @@ function initEaster() {
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// shuffle the easter images
|
||||
let currentIndex = images.length;
|
||||
let randomIndex;
|
||||
while (currentIndex != 0) {
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
[images[currentIndex], images[randomIndex]] = [images[randomIndex], images[currentIndex]];
|
||||
}
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const eggDiv = document.createElement("div");
|
||||
eggDiv.className = "easter";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = images[i];
|
||||
|
||||
// set random animation duration
|
||||
if (enableDiffrentDuration) {
|
||||
const randomAnimationDuration = Math.random() * 10 + 6; // delay (6s to 10s)
|
||||
const randomAnimationDuration2 = Math.random() * 5 + 2; // delay (2s to 5s)
|
||||
eggDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`;
|
||||
createEasterGrassAndEggs(container);
|
||||
addHoppingRabbit(container);
|
||||
|
||||
// Add resize listener to regenerate meadow
|
||||
window.addEventListener('resize', () => {
|
||||
if(document.querySelector('.easter-container')) {
|
||||
createEasterGrassAndEggs(container);
|
||||
}
|
||||
});
|
||||
|
||||
eggDiv.appendChild(img);
|
||||
container.appendChild(eggDiv);
|
||||
}
|
||||
|
||||
addHoppingRabbit();
|
||||
}
|
||||
|
||||
|
||||
// initialize easter and add random easter after the DOM is loaded
|
||||
function initializeEaster() {
|
||||
if (!easter) return; // exit if easter are disabled
|
||||
initEaster();
|
||||
toggleEaster();
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
if (randomEaster && (screenWidth > 768 || randomEasterMobile)) { // add random easter only on larger screens, unless enabled for mobile devices
|
||||
addRandomEaster(easterEggCount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
initializeEaster();
|
||||
BIN
Jellyfin.Plugin.Seasonals/Web/easter_images/Osterhase.gif
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
Jellyfin.Plugin.Seasonals/Web/easter_images/Osterhase_1.gif
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 26 KiB |
53
Jellyfin.Plugin.Seasonals/Web/eid.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.eid-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.eid-symbol {
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.eid-symbol.floating-star {
|
||||
opacity: 0;
|
||||
animation: eid-twinkle 4s ease-in-out infinite;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.lantern-rope {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(to bottom, rgba(255, 215, 0, 0.1), rgba(255, 215, 0, 0.6));
|
||||
transform-origin: top center;
|
||||
animation: lantern-swing 4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.lantern-emoji {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 2.5em;
|
||||
filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.5));
|
||||
}
|
||||
|
||||
@keyframes lantern-swing {
|
||||
0% { transform: rotate(-8deg); }
|
||||
100% { transform: rotate(8deg); }
|
||||
}
|
||||
|
||||
@keyframes eid-twinkle {
|
||||
0% { transform: scale(0.8); opacity: 0; text-shadow: 0 0 5px gold; }
|
||||
50% { transform: scale(1.2); opacity: 0.8; text-shadow: 0 0 20px gold; }
|
||||
100% { transform: scale(0.8); opacity: 0; text-shadow: 0 0 5px gold; }
|
||||
}
|
||||
96
Jellyfin.Plugin.Seasonals/Web/eid.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const config = window.SeasonalsPluginConfig?.Eid || {};
|
||||
const eid = config.EnableEid !== undefined ? config.EnableEid : true;
|
||||
|
||||
const eidSymbols = ['🌙', '⭐', '✨'];
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
function toggleEid() {
|
||||
const container = document.querySelector('.eid-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('Eid hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Eid visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleEid);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
function createEid(container) {
|
||||
const starCount = 20;
|
||||
const lanternCount = Math.floor(Math.random() * 3) + 4; // 4 to 6 lanterns
|
||||
|
||||
// Create evenly spaced lanterns
|
||||
const segmentWidth = 100 / lanternCount;
|
||||
for (let i = 0; i < lanternCount; i++) {
|
||||
const symbol = document.createElement('div');
|
||||
symbol.className = 'eid-symbol lantern-rope';
|
||||
|
||||
// Base position within segment, with slight random jitter
|
||||
const baseLeft = (i * segmentWidth) + (segmentWidth * 0.2);
|
||||
const jitter = Math.random() * (segmentWidth * 0.6);
|
||||
symbol.style.left = `${baseLeft + jitter}%`;
|
||||
|
||||
symbol.style.animationDelay = `${Math.random() * -4}s`;
|
||||
const ropeLen = 15 + Math.random() * 15; // 15vh to 30vh
|
||||
symbol.style.height = `${ropeLen}vh`;
|
||||
|
||||
const lanternSpan = document.createElement('span');
|
||||
lanternSpan.className = 'lantern-emoji';
|
||||
lanternSpan.textContent = '🏮';
|
||||
symbol.appendChild(lanternSpan);
|
||||
|
||||
container.appendChild(symbol);
|
||||
}
|
||||
|
||||
// Create random floating stars
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const symbol = document.createElement('div');
|
||||
symbol.className = 'eid-symbol floating-star';
|
||||
symbol.textContent = eidSymbols[Math.floor(Math.random() * eidSymbols.length)];
|
||||
symbol.style.left = `${Math.random() * 100}%`;
|
||||
symbol.style.top = `${Math.random() * 100}%`;
|
||||
symbol.style.animationDelay = `${Math.random() * -5}s`;
|
||||
|
||||
symbol.addEventListener('animationiteration', () => {
|
||||
symbol.style.left = `${Math.random() * 90 + 5}%`;
|
||||
symbol.style.top = `${Math.random() * 100}%`;
|
||||
});
|
||||
|
||||
container.appendChild(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeEid() {
|
||||
if (!eid) return;
|
||||
const container = document.querySelector('.eid-container') || document.createElement("div");
|
||||
if (!document.querySelector('.eid-container')) {
|
||||
container.className = "eid-container";
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
createEid(container);
|
||||
toggleEid();
|
||||
}
|
||||
initializeEid();
|
||||
42
Jellyfin.Plugin.Seasonals/Web/eurovision.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.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;
|
||||
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); }
|
||||
}
|
||||
101
Jellyfin.Plugin.Seasonals/Web/eurovision.js
Normal file
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleEurovision);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeEurovision() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
toggleEurovision();
|
||||
}
|
||||
|
||||
initializeEurovision();
|
||||
86
Jellyfin.Plugin.Seasonals/Web/filmnoir.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.filmnoir-tint {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
background-color: #8c7355;
|
||||
mix-blend-mode: color;
|
||||
}
|
||||
|
||||
.filmnoir-effects {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
/* Film grain */
|
||||
.filmnoir-grain {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="3" stitchTiles="stitch"/></filter><rect width="200" height="200" filter="url(%23n)" opacity="0.4"/></svg>');
|
||||
animation: grain-dance 0.2s steps(4) infinite;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Vignette */
|
||||
.filmnoir-vignette {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at center, transparent 50%, rgba(0,0,0,0.8) 120%);
|
||||
box-shadow: inset 0 0 100px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
/* Occasional flicker and scratch */
|
||||
.filmnoir-scratches {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent 50%, rgba(255,255,255,0.2) 51%, transparent 52%);
|
||||
background-size: 200% 100%;
|
||||
animation: scratch 4s infinite linear, flicker 6s infinite alternate;
|
||||
opacity: 0.2;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
@keyframes grain-dance {
|
||||
0% { transform: translate(0,0); }
|
||||
25% { transform: translate(-5%,-5%); }
|
||||
50% { transform: translate(-10%,5%); }
|
||||
75% { transform: translate(5%,-10%); }
|
||||
100% { transform: translate(0,0); }
|
||||
}
|
||||
|
||||
@keyframes scratch {
|
||||
0% { background-position: -200% 0; }
|
||||
10% { background-position: 200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0% { opacity: 0.2; }
|
||||
5% { opacity: 0.1; }
|
||||
10% { opacity: 0.3; }
|
||||
15% { opacity: 0.2; }
|
||||
50% { opacity: 0.15; }
|
||||
55% { opacity: 0.25; }
|
||||
100% { opacity: 0.2; }
|
||||
}
|
||||
79
Jellyfin.Plugin.Seasonals/Web/filmnoir.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const config = window.SeasonalsPluginConfig?.FilmNoir || {};
|
||||
const filmnoir = config.EnableFilmNoir !== undefined ? config.EnableFilmNoir : true;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
function toggleFilmNoir() {
|
||||
const tint = document.querySelector('.filmnoir-tint');
|
||||
const effects = document.querySelector('.filmnoir-effects');
|
||||
if (!tint || !effects) 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) {
|
||||
tint.style.display = 'none';
|
||||
effects.style.display = 'none';
|
||||
if (!msgPrinted) {
|
||||
console.log('FilmNoir hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
tint.style.display = 'block';
|
||||
effects.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('FilmNoir visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleFilmNoir);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
function createFilmNoir() {
|
||||
if (!document.querySelector('.filmnoir-tint')) {
|
||||
const tint = document.createElement('div');
|
||||
tint.className = 'filmnoir-tint';
|
||||
tint.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(tint);
|
||||
}
|
||||
|
||||
let effects = document.querySelector('.filmnoir-effects');
|
||||
if (!effects) {
|
||||
effects = document.createElement('div');
|
||||
effects.className = 'filmnoir-effects';
|
||||
effects.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(effects);
|
||||
|
||||
const vignette = document.createElement('div');
|
||||
vignette.className = 'filmnoir-vignette';
|
||||
|
||||
const grain = document.createElement('div');
|
||||
grain.className = 'filmnoir-grain';
|
||||
|
||||
const scratches = document.createElement('div');
|
||||
scratches.className = 'filmnoir-scratches';
|
||||
|
||||
effects.appendChild(grain);
|
||||
effects.appendChild(scratches);
|
||||
effects.appendChild(vignette);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function initializeFilmNoir() {
|
||||
if (!filmnoir) return;
|
||||
|
||||
createFilmNoir();
|
||||
toggleFilmNoir();
|
||||
}
|
||||
|
||||
initializeFilmNoir();
|
||||
@@ -7,6 +7,7 @@
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.rocket-trail {
|
||||
@@ -34,6 +35,7 @@
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(calc(var(--trailEndY) - var(--trailStartY)));
|
||||
opacity: 0;
|
||||
@@ -46,6 +48,7 @@
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--x), var(--y));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const config = window.SeasonalsPluginConfig?.fireworks || {};
|
||||
const config = window.SeasonalsPluginConfig?.Fireworks || {};
|
||||
|
||||
const fireworks = config.enableFireworks !== undefined ? config.enableFireworks : true; // enable/disable fireworks
|
||||
const scrollFireworks = config.scrollFireworks !== undefined ? config.scrollFireworks : true; // enable fireworks to scroll with page content
|
||||
const particlesPerFirework = config.particleCount || 50; // count of particles per firework (Warning: High values may affect performance)
|
||||
const minFireworks = config.minFireworks || 3; // minimum number of simultaneous fireworks
|
||||
const maxFireworks = config.maxFireworks || 6; // maximum number of simultaneous fireworks
|
||||
const intervalOfFireworks = config.launchInterval || 3200; // interval for the fireworks in milliseconds
|
||||
const fireworks = config.EnableFireworks !== undefined ? config.EnableFireworks : true; // enable/disable fireworks
|
||||
const scrollFireworks = config.ScrollFireworks !== undefined ? config.ScrollFireworks : true; // enable fireworks to scroll with page content
|
||||
const particlesPerFirework = config.ParticleCount || 50; // count of particles per firework (Warning: High values may affect performance)
|
||||
const minFireworks = config.MinFireworks || 3; // minimum number of simultaneous fireworks
|
||||
const maxFireworks = config.MaxFireworks || 6; // maximum number of simultaneous fireworks
|
||||
const intervalOfFireworks = config.LaunchInterval || 3200; // interval for the fireworks in milliseconds
|
||||
|
||||
// array of color palettes for the fireworks
|
||||
const colorPalettes = [
|
||||
@@ -42,6 +42,11 @@ function toggleFirework() {
|
||||
}
|
||||
} else {
|
||||
fireworksContainer.style.display = 'block'; // show fireworks
|
||||
|
||||
if (scrollFireworks) {
|
||||
fireworksContainer.style.height = `${document.documentElement.scrollHeight}px`;
|
||||
}
|
||||
|
||||
if (msgPrinted) {
|
||||
console.log('Fireworks visible');
|
||||
startFireworks();
|
||||
@@ -55,9 +60,9 @@ const observer = new MutationObserver(toggleFirework);
|
||||
|
||||
// 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)
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
@@ -117,8 +122,8 @@ function launchFirework() {
|
||||
let startY, endY;
|
||||
if (scrollFireworks) {
|
||||
// Y-position considers scrolling
|
||||
startY = window.scrollY + window.innerHeight; // Bottom edge of the window plus the scroll offset
|
||||
endY = Math.random() * window.innerHeight * 0.5 + window.innerHeight * 0.2 + window.scrollY; // Area around the middle, but also with scrolling
|
||||
startY = window.scrollY + window.innerHeight;
|
||||
endY = window.scrollY + (Math.random() * window.innerHeight * 0.5 + window.innerHeight * 0.2);
|
||||
} else {
|
||||
startY = window.innerHeight; // Bottom edge of the window
|
||||
endY = Math.random() * window.innerHeight * 0.5 + window.innerHeight * 0.2; // Area around the middle
|
||||
@@ -133,16 +138,29 @@ function launchFirework() {
|
||||
}, 1000); // or 1200
|
||||
}
|
||||
|
||||
// Start the firework routine
|
||||
// Start the firework routine
|
||||
function startFireworks() {
|
||||
const fireworkContainer = document.querySelector('.fireworks') || document.createElement("div");
|
||||
let fireworkContainer = document.querySelector('.fireworks');
|
||||
|
||||
if (!document.querySelector('.fireworks')) {
|
||||
if (!fireworkContainer) {
|
||||
fireworkContainer = document.createElement("div");
|
||||
fireworkContainer.className = "fireworks";
|
||||
fireworkContainer.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(fireworkContainer);
|
||||
}
|
||||
|
||||
|
||||
fireworkContainer.style.position = scrollFireworks ? 'absolute' : 'fixed';
|
||||
|
||||
if (scrollFireworks) {
|
||||
fireworkContainer.style.height = `${document.documentElement.scrollHeight}px`;
|
||||
fireworkContainer.style.width = '100%';
|
||||
fireworkContainer.style.top = '0';
|
||||
fireworkContainer.style.left = '0';
|
||||
} else {
|
||||
fireworkContainer.style.height = '100%';
|
||||
fireworkContainer.style.width = '100%';
|
||||
}
|
||||
|
||||
fireworksInterval = setInterval(() => {
|
||||
const randomCount = Math.floor(Math.random() * maxFireworks) + minFireworks;
|
||||
for (let i = 0; i < randomCount; i++) {
|
||||
|
||||
34
Jellyfin.Plugin.Seasonals/Web/friday13.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.friday13-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.friday13-cat {
|
||||
position: absolute;
|
||||
width: 150px; /* MARK: Cat size */
|
||||
height: auto;
|
||||
user-select: none;
|
||||
animation-timing-function: linear;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes cat-walk-right {
|
||||
0% { left: -10vw; transform: scaleX(-1); opacity: 1; }
|
||||
99% { left: 110vw; transform: scaleX(-1); opacity: 1; }
|
||||
100% { opacity: 0; transform: scaleX(-1); left: 110vw; }
|
||||
}
|
||||
|
||||
@keyframes cat-walk-left {
|
||||
0% { left: 110vw; transform: scaleX(1); opacity: 1; }
|
||||
99% { left: -10vw; transform: scaleX(1); opacity: 1; }
|
||||
100% { opacity: 0; transform: scaleX(1); left: -10vw; }
|
||||
}
|
||||
|
||||
83
Jellyfin.Plugin.Seasonals/Web/friday13.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const config = window.SeasonalsPluginConfig?.Friday13 || {};
|
||||
const friday13 = config.EnableFriday13 !== undefined ? config.EnableFriday13 : true;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
function toggleFriday13() {
|
||||
const container = document.querySelector('.friday13-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('Friday13 hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Friday13 visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleFriday13);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
function createFriday13(container) {
|
||||
function spawnCat() {
|
||||
// MARK: Height of the cat from bottom
|
||||
const catBottomPosition = "-15px";
|
||||
// MARK: Time it takes for the cat to cross the screen
|
||||
const catWalkDurationSeconds = 20;
|
||||
|
||||
const cat = document.createElement('img');
|
||||
cat.className = 'friday13-cat';
|
||||
cat.src = '../Seasonals/Resources/friday_assets/black-cat.gif';
|
||||
cat.style.bottom = catBottomPosition;
|
||||
|
||||
// Either walk left to right or right to left
|
||||
const dir = Math.random() > 0.5 ? 'right' : 'left';
|
||||
cat.style.animationName = `cat-walk-${dir}`;
|
||||
cat.style.animationDuration = `${catWalkDurationSeconds}s`;
|
||||
cat.style.animationIterationCount = `1`; // play once and remove
|
||||
cat.style.animationFillMode = `forwards`;
|
||||
|
||||
container.appendChild(cat);
|
||||
|
||||
// Remove and respawn
|
||||
setTimeout(() => {
|
||||
if (cat.parentNode) {
|
||||
cat.parentNode.removeChild(cat);
|
||||
}
|
||||
// Respawn with random delay between 5 to 25 seconds
|
||||
setTimeout(spawnCat, Math.random() * 20000 + 5000);
|
||||
}, (catWalkDurationSeconds * 1000) + 500); // Wait for duration + 500ms safety margin
|
||||
}
|
||||
|
||||
// Initial spawn with random delay
|
||||
setTimeout(spawnCat, Math.random() * 5000);
|
||||
}
|
||||
|
||||
function initializeFriday13() {
|
||||
if (!friday13) return;
|
||||
const container = document.querySelector('.friday13-container') || document.createElement("div");
|
||||
if (!document.querySelector('.friday13-container')) {
|
||||
container.className = "friday13-container";
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
createFriday13(container);
|
||||
toggleFriday13();
|
||||
}
|
||||
initializeFriday13();
|
||||
BIN
Jellyfin.Plugin.Seasonals/Web/friday_assets/black-cat.gif
Normal file
|
After Width: | Height: | Size: 88 KiB |
74
Jellyfin.Plugin.Seasonals/Web/frost.css
Normal file
@@ -0,0 +1,74 @@
|
||||
.frost-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.frost-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
/* A glowing white-blue gradient from edges */
|
||||
background: radial-gradient(ellipse at center, transparent 60%, rgba(180, 220, 255, 0.4) 100%);
|
||||
box-shadow: inset 0 0 60px rgba(200, 230, 255, 0.5), inset 0 0 120px rgba(255, 255, 255, 0.3);
|
||||
|
||||
filter: url('#frost-filter');
|
||||
|
||||
animation: frost-creep 4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Subtle repeating star/crystal pattern */
|
||||
.frost-crystals {
|
||||
position: absolute;
|
||||
top: -5%;
|
||||
left: -5%;
|
||||
width: 110%;
|
||||
height: 110%;
|
||||
/* Use multi-layered star patterns for a random, crystalline spread */
|
||||
background-image:
|
||||
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><circle cx="10" cy="10" r="1.5" fill="rgba(255,255,255,0.2)"/><circle cx="40" cy="30" r="1" fill="rgba(255,255,255,0.15)"/><circle cx="20" cy="50" r="2" fill="rgba(255,255,255,0.1)"/><path d="M50 10 L51 15 L56 16 L51 17 L50 22 L49 17 L44 16 L49 15 Z" fill="rgba(255,255,255,0.2)"/></svg>'),
|
||||
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><circle cx="5" cy="20" r="1" fill="rgba(255,255,255,0.15)"/><circle cx="25" cy="5" r="1.5" fill="rgba(255,255,255,0.1)"/><path d="M20 20 L21 23 L24 24 L21 25 L20 28 L19 25 L16 24 L19 23 Z" fill="rgba(255,255,255,0.15)"/></svg>'),
|
||||
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30"><circle cx="15" cy="15" r="1" fill="rgba(255,255,255,0.2)"/><circle cx="5" cy="5" r="0.8" fill="rgba(255,255,255,0.1)"/></svg>');
|
||||
background-repeat: repeat;
|
||||
background-size: 110px 110px, 60px 60px, 30px 30px;
|
||||
background-position: 0 0, 15px 15px, 5px 10px;
|
||||
mix-blend-mode: overlay;
|
||||
|
||||
/* Mask out the center so crystals only appear strongly on the edges */
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, transparent 50%, black 100%);
|
||||
mask-image: radial-gradient(ellipse at center, transparent 50%, black 100%);
|
||||
|
||||
animation: frost-shimmer 6s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes frost-creep {
|
||||
0% {
|
||||
opacity: 0;
|
||||
box-shadow: inset 0 0 10px rgba(200, 230, 255, 0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: inset 0 0 60px rgba(200, 230, 255, 0.5), inset 0 0 120px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes frost-shimmer {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
75
Jellyfin.Plugin.Seasonals/Web/frost.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const config = window.SeasonalsPluginConfig?.Frost || {};
|
||||
|
||||
const frost = config.EnableFrost !== undefined ? config.EnableFrost : true;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
function toggleFrost() {
|
||||
const container = document.querySelector('.frost-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('Frost hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Frost visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleFrost);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
function createFrost(container) {
|
||||
const frostLayer = document.createElement('div');
|
||||
frostLayer.className = 'frost-layer';
|
||||
|
||||
const frostCrystals = document.createElement('div');
|
||||
frostCrystals.className = 'frost-crystals';
|
||||
|
||||
// An SVG filter to make things look "frozen"/distorted around the edges
|
||||
const svgFilter = document.createElement('div');
|
||||
svgFilter.innerHTML = `
|
||||
<svg style="display:none;">
|
||||
<filter id="frost-filter">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="3" result="noise" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="5" xChannelSelector="R" yChannelSelector="G" />
|
||||
</filter>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
frostLayer.appendChild(frostCrystals);
|
||||
container.appendChild(frostLayer);
|
||||
container.appendChild(svgFilter);
|
||||
}
|
||||
|
||||
function initializeFrost() {
|
||||
if (!frost) return;
|
||||
|
||||
const container = document.querySelector('.frost-container') || document.createElement("div");
|
||||
|
||||
if (!document.querySelector('.frost-container')) {
|
||||
container.className = "frost-container";
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
createFrost(container);
|
||||
}
|
||||
|
||||
initializeFrost();
|
||||
@@ -1,14 +1,20 @@
|
||||
.halloween-container {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.halloween {
|
||||
position: fixed;
|
||||
bottom: -10%;
|
||||
z-index: 0;
|
||||
z-index: 15;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
@@ -29,11 +35,11 @@
|
||||
|
||||
@-webkit-keyframes halloween-fall {
|
||||
0% {
|
||||
bottom: -10%
|
||||
bottom: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 100%
|
||||
bottom: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +59,11 @@
|
||||
|
||||
@keyframes halloween-fall {
|
||||
0% {
|
||||
bottom: -10%
|
||||
bottom: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 100%
|
||||
bottom: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,71 +82,183 @@
|
||||
.halloween:nth-of-type(0) {
|
||||
left: 1%;
|
||||
-webkit-animation-delay: 0s, 0s;
|
||||
animation-delay: 0s, 0s
|
||||
animation-delay: 0s, 0s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(1) {
|
||||
left: 10%;
|
||||
-webkit-animation-delay: 1s, 1s;
|
||||
animation-delay: 1s, 1s
|
||||
-webkit-animation-delay: -1s, -1s;
|
||||
animation-delay: -1s, -1s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(2) {
|
||||
left: 20%;
|
||||
-webkit-animation-delay: 6s, .5s;
|
||||
animation-delay: 6s, .5s
|
||||
-webkit-animation-delay: -2s, -2s;
|
||||
animation-delay: -2s, -2s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(3) {
|
||||
left: 30%;
|
||||
-webkit-animation-delay: 4s, 2s;
|
||||
animation-delay: 4s, 2s
|
||||
-webkit-animation-delay: -3s, -3s;
|
||||
animation-delay: -3s, -3s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(4) {
|
||||
left: 40%;
|
||||
-webkit-animation-delay: 2s, 2s;
|
||||
animation-delay: 2s, 2s
|
||||
-webkit-animation-delay: -4s, -4s;
|
||||
animation-delay: -4s, -4s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(5) {
|
||||
left: 50%;
|
||||
-webkit-animation-delay: 8s, 3s;
|
||||
animation-delay: 8s, 3s
|
||||
-webkit-animation-delay: -5s, -5s;
|
||||
animation-delay: -5s, -5s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(6) {
|
||||
left: 60%;
|
||||
-webkit-animation-delay: 6s, 2s;
|
||||
animation-delay: 6s, 2s
|
||||
-webkit-animation-delay: -6s, -6s;
|
||||
animation-delay: -6s, -6s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(7) {
|
||||
left: 70%;
|
||||
-webkit-animation-delay: 2.5s, 1s;
|
||||
animation-delay: 2.5s, 1s
|
||||
-webkit-animation-delay: -7s, -7s;
|
||||
animation-delay: -7s, -7s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(8) {
|
||||
left: 80%;
|
||||
-webkit-animation-delay: 1s, 0s;
|
||||
animation-delay: 1s, 0s
|
||||
-webkit-animation-delay: -8s, -8s;
|
||||
animation-delay: -8s, -8s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(9) {
|
||||
left: 90%;
|
||||
-webkit-animation-delay: 3s, 1.5s;
|
||||
animation-delay: 3s, 1.5s
|
||||
-webkit-animation-delay: -9s, -9s;
|
||||
animation-delay: -9s, -9s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(10) {
|
||||
left: 25%;
|
||||
-webkit-animation-delay: 2s, 0s;
|
||||
animation-delay: 2s, 0s
|
||||
-webkit-animation-delay: -10s, -10s;
|
||||
animation-delay: -10s, -10s;
|
||||
}
|
||||
|
||||
.halloween:nth-of-type(11) {
|
||||
left: 65%;
|
||||
-webkit-animation-delay: 4s, 2.5s;
|
||||
animation-delay: 4s, 2.5s
|
||||
-webkit-animation-delay: -11s, -11s;
|
||||
animation-delay: -11s, -11s;
|
||||
}
|
||||
|
||||
/* --- Fog Layer --- */
|
||||
.halloween-fog-layer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 40vh;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to top, black, transparent);
|
||||
-webkit-mask-image: linear-gradient(to top, black, transparent);
|
||||
}
|
||||
.halloween-fog-blob {
|
||||
position: absolute;
|
||||
bottom: -10vh;
|
||||
width: 150vw;
|
||||
height: 50vh;
|
||||
background: radial-gradient(ellipse at center, rgba(120, 130, 140, 0.4) 0%, transparent 60%);
|
||||
border-radius: 50%;
|
||||
filter: blur(15px);
|
||||
}
|
||||
.halloween-fog-blob:nth-child(1) {
|
||||
left: -20vw;
|
||||
animation: fog-float1 25s ease-in-out infinite alternate;
|
||||
}
|
||||
.halloween-fog-blob:nth-child(2) {
|
||||
left: -50vw;
|
||||
background: radial-gradient(ellipse at center, rgba(100, 110, 120, 0.3) 0%, transparent 65%);
|
||||
animation: fog-float2 35s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes fog-float1 {
|
||||
0% { transform: translateX(0) scale(1); opacity: 0.8; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translateX(20vw) scale(1.1); opacity: 0.6; }
|
||||
}
|
||||
@keyframes fog-float2 {
|
||||
0% { transform: translateX(0) scale(1.1); opacity: 0.7; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translateX(30vw) scale(1); opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* --- Spiders --- */
|
||||
.halloween-spider-wrapper {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 1002;
|
||||
transform-origin: top;
|
||||
will-change: transform;
|
||||
pointer-events: auto;
|
||||
padding: 20px; /* Increase hit area safely */
|
||||
}
|
||||
.halloween-thread {
|
||||
width: 30px; /* Wider hit area for mouse interaction */
|
||||
height: 100vh;
|
||||
margin-top: -100vh;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.halloween-thread::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, rgba(200, 200, 200, 0.1), rgba(200, 200, 200, 0.6));
|
||||
}
|
||||
.halloween-spider {
|
||||
animation: spider-swing 3s ease-in-out infinite alternate;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
/* MARK: SPIDER SWAY CONFIGURATION */
|
||||
/* Adjust degrees in 'rotate(...)' to change how far spider and thread swing in wind. */
|
||||
@keyframes wind-sway {
|
||||
0% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(2deg); }
|
||||
75% { transform: rotate(-2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes spider-drop {
|
||||
0% { transform: translateY(-50px); }
|
||||
30% { transform: translateY(var(--drop-height, 50vh)); }
|
||||
60% { transform: translateY(var(--drop-height, 50vh)); }
|
||||
100% { transform: translateY(-50px); }
|
||||
}
|
||||
@keyframes spider-swing {
|
||||
0% { transform: rotate(-10deg); }
|
||||
100% { transform: rotate(10deg); }
|
||||
}
|
||||
|
||||
/* Mice */
|
||||
.halloween-mouse {
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
will-change: left;
|
||||
}
|
||||
@keyframes mouse-run-right {
|
||||
0% { left: -10vw; }
|
||||
100% { left: 110vw; }
|
||||
}
|
||||
@keyframes mouse-run-left {
|
||||
0% { left: 110vw; }
|
||||
100% { left: -10vw; }
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
const config = window.SeasonalsPluginConfig?.halloween || {};
|
||||
const config = window.SeasonalsPluginConfig?.Halloween || {};
|
||||
|
||||
const halloween = config.enableHalloween !== undefined ? config.enableHalloween : true; // enable/disable halloween
|
||||
const randomSymbols = config.enableRandomSymbols !== undefined ? config.enableRandomSymbols : true; // enable more random symbols
|
||||
const randomSymbolsMobile = config.enableRandomSymbolsMobile !== undefined ? config.enableRandomSymbolsMobile : false; // enable random symbols on mobile devices (Warning: High values may affect performance)
|
||||
const enableDiffrentDuration = config.enableDifferentDuration !== undefined ? config.enableDifferentDuration : true; // enable different duration for the random halloween symbols
|
||||
const halloweenCount = config.symbolCount || 25; // count of random extra symbols
|
||||
const halloween = config.EnableHalloween !== undefined ? config.EnableHalloween : true; // enable/disable halloween
|
||||
const randomSymbols = config.EnableRandomSymbols !== undefined ? config.EnableRandomSymbols : true; // enable more random symbols
|
||||
const randomSymbolsMobile = config.EnableRandomSymbolsMobile !== undefined ? config.EnableRandomSymbolsMobile : false; // enable random symbols on mobile devices (Warning: High values may affect performance)
|
||||
const enableDiffrentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; // enable different duration for the random halloween symbols
|
||||
const enableSpiders = config.EnableSpiders !== undefined ? config.EnableSpiders : true;
|
||||
const enableMice = config.EnableMice !== undefined ? config.EnableMice : true;
|
||||
const halloweenCount = config.SymbolCount || 25; // count of random extra symbols
|
||||
|
||||
const images = [
|
||||
"../Seasonals/Resources/halloween_images/ghost_20x20.png",
|
||||
"../Seasonals/Resources/halloween_images/bat_20x20.png",
|
||||
"../Seasonals/Resources/halloween_images/pumpkin_20x20.png",
|
||||
];
|
||||
|
||||
let msgPrinted = false; // flag to prevent multiple console messages
|
||||
|
||||
@@ -36,21 +44,13 @@ function toggleHalloween() {
|
||||
|
||||
// observe changes in the DOM
|
||||
const observer = new MutationObserver(toggleHalloween);
|
||||
|
||||
// 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)
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
const images = [
|
||||
"Seasonals/Resources/halloween_images/ghost_20x20.png",
|
||||
"Seasonals/Resources/halloween_images/bat_20x20.png",
|
||||
"Seasonals/Resources/halloween_images/pumpkin_20x20.png",
|
||||
];
|
||||
|
||||
function addRandomSymbols(count) {
|
||||
const halloweenContainer = document.querySelector('.halloween-container'); // get the halloween container
|
||||
if (!halloweenContainer) return; // exit if halloween container is not found
|
||||
@@ -74,7 +74,7 @@ function addRandomSymbols(count) {
|
||||
// set random horizontal position, animation delay and size(uncomment lines to enable)
|
||||
const randomLeft = Math.random() * 100; // position (0% to 100%)
|
||||
const randomAnimationDelay = Math.random() * 10; // delay (0s to 10s)
|
||||
const randomAnimationDelay2 = Math.random() * 3; // delay (0s to 3s)
|
||||
const randomAnimationDelay2 = -(Math.random() * 3); // delay (-3s to 0s)
|
||||
|
||||
// apply styles
|
||||
halloweenDiv.style.left = `${randomLeft}%`;
|
||||
@@ -124,12 +124,137 @@ function createHalloween() {
|
||||
}
|
||||
}
|
||||
|
||||
// create fog layer
|
||||
function createFog(container) {
|
||||
const fogContainer = document.createElement('div');
|
||||
fogContainer.className = 'halloween-fog-layer';
|
||||
|
||||
const fog1 = document.createElement('div');
|
||||
fog1.className = 'halloween-fog-blob';
|
||||
|
||||
const fog2 = document.createElement('div');
|
||||
fog2.className = 'halloween-fog-blob';
|
||||
|
||||
fogContainer.appendChild(fog1);
|
||||
fogContainer.appendChild(fog2);
|
||||
container.appendChild(fogContainer);
|
||||
}
|
||||
|
||||
// create dropping spiders
|
||||
function createSpider(container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'halloween-spider-wrapper';
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="halloween-sway" style="display:flex; flex-direction:column; align-items:center; transform-origin: 50% -100vh;">
|
||||
<div class="halloween-thread"></div>
|
||||
<svg class="halloween-spider" viewBox="0 0 24 24" width="30" height="30">
|
||||
<circle cx="12" cy="12" r="6" fill="#1a1a1a"/>
|
||||
<!-- left legs -->
|
||||
<path d="M12 12 l-8 -4 M12 12 l-9 0 M12 12 l-8 4 M12 12 l-6 8" stroke="#1a1a1a" stroke-width="1.5" stroke-linecap="round" fill="none"/>
|
||||
<!-- right legs -->
|
||||
<path d="M12 12 l8 -4 M12 12 l9 0 M12 12 l8 4 M12 12 l6 8" stroke="#1a1a1a" stroke-width="1.5" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="10" cy="14" r="1.5" fill="#ff3333"/>
|
||||
<circle cx="14" cy="14" r="1.5" fill="#ff3333"/>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wrapper.style.left = `${10 + Math.random() * 80}%`;
|
||||
const dropHeight = 30 + Math.random() * 50; // 30vh to 80vh
|
||||
wrapper.style.setProperty('--drop-height', `${dropHeight}vh`);
|
||||
|
||||
const duration = Math.random() * 6 + 6; // 6-12s drop
|
||||
wrapper.style.animation = `spider-drop ${duration}s ease-in-out forwards`;
|
||||
|
||||
// Start the sway animation only after the drop completes (30% of total duration)
|
||||
const sway = wrapper.querySelector('.halloween-sway');
|
||||
sway.style.animation = `wind-sway 8s ease-in-out ${duration * 0.3}s infinite`;
|
||||
|
||||
// Spider retreat logic
|
||||
let isRetreating = false;
|
||||
wrapper.addEventListener('mouseenter', () => {
|
||||
if (isRetreating) return;
|
||||
isRetreating = true;
|
||||
// Retreat smoothly by pushing margin up
|
||||
wrapper.style.transition = 'margin-top 0.4s ease-in';
|
||||
wrapper.style.marginTop = '-100vh';
|
||||
|
||||
setTimeout(() => {
|
||||
wrapper.remove();
|
||||
setTimeout(() => createSpider(container), Math.random() * 5000 + 1000);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
wrapper.addEventListener('animationend', () => {
|
||||
if (isRetreating) return;
|
||||
wrapper.remove();
|
||||
setTimeout(() => createSpider(container), Math.random() * 5000 + 1000);
|
||||
});
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// create scurrying mice
|
||||
function createMouse(container) {
|
||||
const mouse = document.createElement('div');
|
||||
mouse.className = 'halloween-mouse';
|
||||
mouse.innerHTML = `
|
||||
<svg viewBox="0 0 30 15" width="40" height="20">
|
||||
<ellipse cx="15" cy="10" rx="10" ry="5" fill="#111"/>
|
||||
<circle cx="24" cy="10" r="4" fill="#111"/>
|
||||
<circle cx="24" cy="6" r="3" fill="#333"/>
|
||||
<path d="M 5 10 Q 0 10 0 2" stroke="#111" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const direction = Math.random() > 0.5 ? 'right' : 'left';
|
||||
const duration = Math.random() * 3 + 2; // 2-5s run (fast)
|
||||
|
||||
if (direction === 'right') {
|
||||
mouse.style.animation = `mouse-run-right ${duration}s linear forwards`;
|
||||
mouse.style.transform = 'scaleX(1)';
|
||||
} else {
|
||||
mouse.style.animation = `mouse-run-left ${duration}s linear forwards`;
|
||||
mouse.style.transform = 'scaleX(-1)';
|
||||
}
|
||||
|
||||
mouse.style.bottom = `5px`; // Fixated bottom edge
|
||||
|
||||
mouse.addEventListener('animationend', () => {
|
||||
mouse.remove();
|
||||
setTimeout(() => createMouse(container), Math.random() * 4000 + 2000);
|
||||
});
|
||||
|
||||
container.appendChild(mouse);
|
||||
}
|
||||
|
||||
// initialize halloween
|
||||
function initializeHalloween() {
|
||||
if (!halloween) return; // exit if halloween is disabled
|
||||
createHalloween();
|
||||
toggleHalloween();
|
||||
|
||||
const container = document.querySelector('.halloween-container');
|
||||
|
||||
if (container) {
|
||||
createFog(container);
|
||||
|
||||
// Add a few spiders
|
||||
if (enableSpiders) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
setTimeout(() => createSpider(container), Math.random() * 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a few mice
|
||||
if (enableMice) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
setTimeout(() => createMouse(container), Math.random() * 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const screenWidth = window.innerWidth; // get the screen width to detect mobile devices
|
||||
if (randomSymbols && (screenWidth > 768 || randomSymbolsMobile)) { // add random halloweens only on larger screens, unless enabled for mobile devices
|
||||
addRandomSymbols(halloweenCount);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
.hearts-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.heart {
|
||||
position: fixed;
|
||||
bottom: -10%;
|
||||
z-index: 0;
|
||||
z-index: 15;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
@@ -27,11 +33,11 @@
|
||||
|
||||
@-webkit-keyframes heart-fall {
|
||||
0% {
|
||||
bottom: -10%
|
||||
bottom: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 100%
|
||||
bottom: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +57,11 @@
|
||||
|
||||
@keyframes heart-fall {
|
||||
0% {
|
||||
bottom: -10%
|
||||
bottom: -10%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 100%
|
||||
bottom: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const config = window.SeasonalsPluginConfig?.hearts || {};
|
||||
const config = window.SeasonalsPluginConfig?.Hearts || {};
|
||||
|
||||
const hearts = config.enableHearts !== undefined ? config.enableHearts : true; // enable/disable hearts
|
||||
const randomSymbols = config.enableRandomSymbols !== undefined ? config.enableRandomSymbols : true; // enable more random symbols
|
||||
const randomSymbolsMobile = config.enableRandomSymbolsMobile !== undefined ? config.enableRandomSymbolsMobile : false; // enable random symbols on mobile devices (Warning: High values may affect performance)
|
||||
const enableDiffrentDuration = config.enableDifferentDuration !== undefined ? config.enableDifferentDuration : true; // enable different animation duration for random symbols
|
||||
const heartsCount = config.symbolCount || 25; // count of random extra symbols
|
||||
const hearts = config.EnableHearts !== undefined ? config.EnableHearts : true; // enable/disable hearts
|
||||
const randomSymbols = config.EnableRandomSymbols !== undefined ? config.EnableRandomSymbols : true; // enable more random symbols
|
||||
const randomSymbolsMobile = config.EnableRandomSymbolsMobile !== undefined ? config.EnableRandomSymbolsMobile : false; // enable random symbols on mobile devices (Warning: High values may affect performance)
|
||||
const enableDiffrentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true; // enable different animation duration for random symbols
|
||||
const heartsCount = config.SymbolCount || 25; // count of random extra symbols
|
||||
|
||||
// Array of hearts characters
|
||||
const heartSymbols = ['❤️', '💕', '💞', '💓', '💗', '💖'];
|
||||
|
||||
let msgPrinted = false; // flag to prevent multiple console messages
|
||||
|
||||
@@ -36,19 +39,13 @@ function toggleHearts() {
|
||||
|
||||
// observe changes in the DOM
|
||||
const observer = new MutationObserver(toggleHearts);
|
||||
|
||||
// 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)
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
// Array of hearts characters
|
||||
const heartSymbols = ['❤️', '💕', '💞', '💓', '💗', '💖'];
|
||||
|
||||
|
||||
function addRandomSymbols(count) {
|
||||
const heartsContainer = document.querySelector('.hearts-container'); // get the hearts container
|
||||
if (!heartsContainer) return; // exit if hearts container is not found
|
||||
|
||||
BIN
Jellyfin.Plugin.Seasonals/Web/mario_assets/mario-running.gif
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/mario_assets/mario.gif
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/mario_assets/toad.gif
Normal file
|
After Width: | Height: | Size: 40 KiB |
77
Jellyfin.Plugin.Seasonals/Web/marioday.css
Normal file
@@ -0,0 +1,77 @@
|
||||
.marioday-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mario-wrapper {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: -100px;
|
||||
animation: mario-run 15s linear infinite;
|
||||
will-change: left, transform;
|
||||
}
|
||||
|
||||
.mario-runner {
|
||||
width: 64px;
|
||||
height: auto;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
display: block;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.mario-jump {
|
||||
animation: jump-arc 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
/* 8-bit coin styling */
|
||||
.mario-coin {
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: #ffd700;
|
||||
border: 4px solid #b8860b;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 4px 4px 0 #fffbea, inset -4px -4px 0 #daa520;
|
||||
animation: pop-up-arc 2s forwards;
|
||||
}
|
||||
|
||||
.mario-coin::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 10px;
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
background: #daa520;
|
||||
}
|
||||
|
||||
@keyframes mario-run {
|
||||
0% { left: -100px; transform: scaleX(1); }
|
||||
45% { left: 110vw; transform: scaleX(1); }
|
||||
50% { left: 110vw; transform: scaleX(-1); }
|
||||
95% { left: -100px; transform: scaleX(-1); }
|
||||
100% { left: -100px; transform: scaleX(1); }
|
||||
}
|
||||
|
||||
@keyframes pop-up-arc {
|
||||
0% { transform: translateY(0) rotateY(0deg); opacity: 0; animation-timing-function: ease-out; }
|
||||
20% { opacity: 1; }
|
||||
50% { transform: translateY(-30vh) rotateY(360deg); opacity: 1; animation-timing-function: ease-in; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateY(20vh) rotateY(720deg); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes jump-arc {
|
||||
0% { transform: translateY(0); }
|
||||
50% { transform: translateY(-25vh); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
82
Jellyfin.Plugin.Seasonals/Web/marioday.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const config = window.SeasonalsPluginConfig?.MarioDay || {};
|
||||
const marioday = config.EnableMarioDay !== undefined ? config.EnableMarioDay : true;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
function toggleMarioDay() {
|
||||
const container = document.querySelector('.marioday-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('MarioDay hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('MarioDay visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleMarioDay);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
function createMarioDay(container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mario-wrapper';
|
||||
|
||||
const mario = document.createElement('img');
|
||||
mario.className = 'mario-runner';
|
||||
mario.src = '../Seasonals/Resources/mario_assets/mario.gif';
|
||||
|
||||
wrapper.appendChild(mario);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Periodically throw out an 8-bit coin
|
||||
setInterval(() => {
|
||||
if (!document.querySelector('.marioday-container')) return;
|
||||
const coin = document.createElement('div');
|
||||
coin.className = 'mario-coin';
|
||||
|
||||
// Grab Mario's current screen position to lock the coin's X coordinate
|
||||
const marioRect = wrapper.getBoundingClientRect();
|
||||
coin.style.left = `${marioRect.left + 16}px`;
|
||||
coin.style.bottom = '35px'; // Adjust for wrapper's bottom offset
|
||||
|
||||
container.appendChild(coin);
|
||||
setTimeout(() => coin.remove(), 2000);
|
||||
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
|
||||
function initializeMarioDay() {
|
||||
if (!marioday) return;
|
||||
|
||||
const container = document.querySelector('.marioday-container') || document.createElement("div");
|
||||
|
||||
if (!document.querySelector('.marioday-container')) {
|
||||
container.className = "marioday-container";
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
createMarioDay(container);
|
||||
toggleMarioDay();
|
||||
}
|
||||
|
||||
initializeMarioDay();
|
||||
11
Jellyfin.Plugin.Seasonals/Web/matrix.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.matrix-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
164
Jellyfin.Plugin.Seasonals/Web/matrix.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const config = window.SeasonalsPluginConfig?.Matrix || {};
|
||||
|
||||
const enabled = config.EnableMatrix !== undefined ? config.EnableMatrix : true;
|
||||
const maxTrails = config.SymbolCount || 25;
|
||||
const backgroundMode = config.EnableMatrixBackground !== undefined ? config.EnableMatrixBackground : false;
|
||||
const matrixChars = config.MatrixChars || '0123456789';
|
||||
|
||||
let msgPrinted = false;
|
||||
let isHidden = false;
|
||||
|
||||
// Toggle Function
|
||||
function toggleMatrix() {
|
||||
const container = document.querySelector('.matrix-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('Matrix hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isHidden) {
|
||||
container.style.display = 'block';
|
||||
isHidden = false;
|
||||
if (msgPrinted) {
|
||||
console.log('Matrix visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleMatrix);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
function createElements() {
|
||||
const container = document.querySelector('.matrix-container') || document.createElement('div');
|
||||
|
||||
if (!document.querySelector('.matrix-container')) {
|
||||
container.className = 'matrix-container';
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
if (backgroundMode) container.style.zIndex = '5';
|
||||
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 = matrixChars.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.matrixInterval) clearInterval(window.matrixInterval);
|
||||
window.matrixInterval = setInterval(loop, 50);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function initializeMatrix() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
toggleMatrix();
|
||||
}
|
||||
|
||||
initializeMatrix();
|
||||
33
Jellyfin.Plugin.Seasonals/Web/oktoberfest.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.oktoberfest-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oktoberfest-symbol {
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
font-size: 2.2em;
|
||||
user-select: none;
|
||||
animation-name: oktoberfest-fall, oktoberfest-sway;
|
||||
animation-timing-function: linear, ease-in-out;
|
||||
animation-iteration-count: infinite, infinite;
|
||||
}
|
||||
|
||||
@keyframes oktoberfest-fall {
|
||||
0% { transform: translateY(0); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
100% { transform: translateY(120vh); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes oktoberfest-sway {
|
||||
0%, 100% { margin-left: 0; }
|
||||
50% { margin-left: 50px; }
|
||||
}
|
||||
67
Jellyfin.Plugin.Seasonals/Web/oktoberfest.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const config = window.SeasonalsPluginConfig?.Oktoberfest || {};
|
||||
const oktoberfest = config.EnableOktoberfest !== undefined ? config.EnableOktoberfest : true;
|
||||
|
||||
const oktoberfestSymbols = ['🥨', '🍺', '🍻', '🥨', '🥨'];
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
// function to check and control the oktoberfest
|
||||
function toggleOktoberfest() {
|
||||
const oktoberfestContainer = document.querySelector('.oktoberfest-container');
|
||||
if (!oktoberfestContainer) 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 oktoberfest if video/trailer player is active or dashboard is visible
|
||||
if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) {
|
||||
oktoberfestContainer.style.display = 'none'; // hide oktoberfest
|
||||
if (!msgPrinted) {
|
||||
console.log('Oktoberfest hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
oktoberfestContainer.style.display = 'block'; // show oktoberfest
|
||||
if (msgPrinted) {
|
||||
console.log('Oktoberfest visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// observe changes in the DOM
|
||||
const observer = new MutationObserver(toggleOktoberfest);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
function createOktoberfest(container) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const symbol = document.createElement('div');
|
||||
symbol.className = 'oktoberfest-symbol';
|
||||
symbol.textContent = oktoberfestSymbols[Math.floor(Math.random() * oktoberfestSymbols.length)];
|
||||
symbol.style.left = `${Math.random() * 100}%`;
|
||||
symbol.style.animationDelay = `${Math.random() * 10}s, ${Math.random() * 5}s`;
|
||||
const duration1 = Math.random() * 5 + 8;
|
||||
const duration2 = Math.random() * 3 + 3;
|
||||
symbol.style.animationDuration = `${duration1}s, ${duration2}s`;
|
||||
|
||||
container.appendChild(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeOktoberfest() {
|
||||
if (!oktoberfest) return;
|
||||
const container = document.querySelector('.oktoberfest-container') || document.createElement("div");
|
||||
if (!document.querySelector('.oktoberfest-container')) {
|
||||
container.className = "oktoberfest-container";
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
createOktoberfest(container);
|
||||
}
|
||||
initializeOktoberfest();
|
||||
71
Jellyfin.Plugin.Seasonals/Web/olympia.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.olympia-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.olympia-symbol {
|
||||
position: absolute;
|
||||
top: -10vh;
|
||||
animation: olympia-fall linear infinite;
|
||||
font-size: 3rem;
|
||||
opacity: 0.95;
|
||||
text-shadow: 0 0 10px rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.olympia-symbol img {
|
||||
width: 6vh;
|
||||
height: auto;
|
||||
max-width: 60px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.olympia-confetti {
|
||||
position: absolute;
|
||||
top: -5vh;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
opacity: 0.85;
|
||||
animation: olympia-confetti-fall linear infinite;
|
||||
border-radius: 4px; /* slightly rounder confetti */
|
||||
}
|
||||
|
||||
@keyframes olympia-fall {
|
||||
0% {
|
||||
transform: translateY(-10vh) rotate(var(--start-rot, 0deg));
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(110vh) rotate(var(--end-rot, 360deg));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes olympia-confetti-fall {
|
||||
0% {
|
||||
transform: translateY(-5vh) rotateX(0deg) rotateY(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
5% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(105vh) rotateX(720deg) rotateY(360deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
135
Jellyfin.Plugin.Seasonals/Web/olympia.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const config = window.SeasonalsPluginConfig?.Olympia || {};
|
||||
|
||||
const olympia = config.EnableOlympia !== undefined ? config.EnableOlympia : true;
|
||||
const symbolCount = config.SymbolCount || 25;
|
||||
const useRandomSymbols = config.EnableRandomSymbols !== undefined ? config.EnableRandomSymbols : true;
|
||||
const enableRandomMobile = config.EnableRandomSymbolsMobile !== undefined ? config.EnableRandomSymbolsMobile : false;
|
||||
const enableDifferentDuration = config.EnableDifferentDuration !== undefined ? config.EnableDifferentDuration : true;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
function toggleOlympia() {
|
||||
const container = document.querySelector('.olympia-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('Olympia hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Olympia visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleOlympia);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
function createOlympia() {
|
||||
const container = document.querySelector('.olympia-container') || document.createElement('div');
|
||||
|
||||
if (!document.querySelector('.olympia-container')) {
|
||||
container.className = 'olympia-container';
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const standardCount = 15;
|
||||
const totalSymbols = symbolCount + standardCount;
|
||||
|
||||
let isMobile = window.matchMedia("only screen and (max-width: 768px)").matches;
|
||||
let finalCount = totalSymbols;
|
||||
|
||||
if (isMobile) {
|
||||
finalCount = enableRandomMobile ? totalSymbols : standardCount;
|
||||
}
|
||||
|
||||
const useRandomDuration = enableDifferentDuration !== false;
|
||||
|
||||
const activeItems = ['gold', 'silver', 'bronze', 'torch'];
|
||||
|
||||
for (let i = 0; i < finalCount; i++) {
|
||||
let symbol = document.createElement('div');
|
||||
|
||||
const randomItem = activeItems[Math.floor(Math.random() * activeItems.length)];
|
||||
symbol.className = `olympia-symbol olympia-${randomItem}`;
|
||||
|
||||
let img = document.createElement('img');
|
||||
img.src = `../Seasonals/Resources/olympia_images/${randomItem}.png`;
|
||||
img.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
this.parentElement.innerHTML = getOlympiaEmojiFallback(randomItem);
|
||||
};
|
||||
symbol.appendChild(img);
|
||||
|
||||
const leftPos = Math.random() * 100;
|
||||
const delaySeconds = Math.random() * 10;
|
||||
|
||||
let durationSeconds = 8;
|
||||
if (useRandomDuration) {
|
||||
durationSeconds = Math.random() * 5 + 6; // 6 to 11 seconds
|
||||
}
|
||||
|
||||
const startRot = Math.random() * 360;
|
||||
symbol.style.setProperty('--start-rot', `${startRot}deg`);
|
||||
symbol.style.setProperty('--end-rot', `${startRot + (Math.random() > 0.5 ? 360 : -360)}deg`);
|
||||
|
||||
symbol.style.left = `${leftPos}vw`;
|
||||
symbol.style.animationDuration = `${durationSeconds}s`;
|
||||
symbol.style.animationDelay = `${delaySeconds}s`;
|
||||
|
||||
container.appendChild(symbol);
|
||||
}
|
||||
|
||||
// Olympic Ring Colors
|
||||
const confettiColors = ['#0081C8', '#FCB131', '#000000', '#00A651', '#EE334E'];
|
||||
const confettiCount = isMobile ? 30 : 60;
|
||||
|
||||
for (let i = 0; i < confettiCount; i++) {
|
||||
let confetti = document.createElement('div');
|
||||
confetti.className = 'olympia-confetti';
|
||||
|
||||
const color = confettiColors[Math.floor(Math.random() * confettiColors.length)];
|
||||
confetti.style.backgroundColor = color;
|
||||
|
||||
const leftPos = Math.random() * 100;
|
||||
const delaySeconds = Math.random() * 8;
|
||||
const duration = Math.random() * 3 + 5;
|
||||
|
||||
confetti.style.left = `${leftPos}vw`;
|
||||
confetti.style.animationDuration = `${duration}s`;
|
||||
confetti.style.animationDelay = `${delaySeconds}s`;
|
||||
|
||||
container.appendChild(confetti);
|
||||
}
|
||||
}
|
||||
|
||||
function getOlympiaEmojiFallback(type) {
|
||||
if (type === 'gold') return '🥇';
|
||||
if (type === 'silver') return '🥈';
|
||||
if (type === 'bronze') return '🥉';
|
||||
if (type === 'torch') return '🔥';
|
||||
return '';
|
||||
}
|
||||
|
||||
function initializeOlympia() {
|
||||
if (!olympia) return;
|
||||
createOlympia();
|
||||
toggleOlympia();
|
||||
}
|
||||
|
||||
initializeOlympia();
|
||||
BIN
Jellyfin.Plugin.Seasonals/Web/olympic_assets/bronze_coin.gif
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/olympic_assets/gold_coin.gif
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/olympic_assets/ring_black.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/olympic_assets/ring_blue.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/olympic_assets/ring_green.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/olympic_assets/ring_red.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/olympic_assets/ring_yellow.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/olympic_assets/silver_coin.gif
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
Jellyfin.Plugin.Seasonals/Web/olympic_assets/torch.gif
Normal file
|
After Width: | Height: | Size: 123 KiB |
67
Jellyfin.Plugin.Seasonals/Web/oscar.css
Normal file
@@ -0,0 +1,67 @@
|
||||
.oscar-container {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10; /* Behind popups but over background */
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oscar-carpet {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 15vh;
|
||||
background: linear-gradient(to top, rgba(139, 0, 0, 0.8) 0%, transparent 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.oscar-spotlights {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.oscar-spotlight {
|
||||
position: absolute;
|
||||
top: -10vh;
|
||||
/* MARK: SPOTLIGHT WIDTH CONFIGURATION */
|
||||
/* To adjust bottom width (spread), change 'width' property (e.g., 20vw for narrow, 40vw for wide). */
|
||||
/* To adjust top width (origin), modify first two percentages in 'clip-path' (e.g., 48% 0, 52% 0 for a very thin start). */
|
||||
width: 30vw;
|
||||
height: 120vh;
|
||||
background: linear-gradient(to bottom, rgba(255, 215, 0, 0.4) 0%, transparent 80%);
|
||||
clip-path: polygon(45% 0, 55% 0, 100% 100%, 0 100%);
|
||||
transform-origin: top center;
|
||||
animation: spotlight-sweep 12s infinite alternate ease-in-out;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.oscar-flash {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 50px 30px rgba(255, 255, 255, 0.8), 0 0 100px 50px rgba(255, 255, 255, 0.5);
|
||||
animation: flash-pop 0.2s cubic-bezier(0.1, 0.8, 0.1, 1);
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
@keyframes spotlight-sweep {
|
||||
0% { transform: rotate(-30deg); }
|
||||
100% { transform: rotate(30deg); }
|
||||
}
|
||||
|
||||
@keyframes flash-pop {
|
||||
0% { transform: scale(0.5); opacity: 1; }
|
||||
50% { transform: scale(2); opacity: 1; }
|
||||
100% { transform: scale(3); opacity: 0; }
|
||||
}
|
||||
94
Jellyfin.Plugin.Seasonals/Web/oscar.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const config = window.SeasonalsPluginConfig?.Oscar || {};
|
||||
const oscar = config.EnableOscar !== undefined ? config.EnableOscar : true;
|
||||
|
||||
let msgPrinted = false;
|
||||
|
||||
function toggleOscar() {
|
||||
const container = document.querySelector('.oscar-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('Oscar hidden');
|
||||
msgPrinted = true;
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
if (msgPrinted) {
|
||||
console.log('Oscar visible');
|
||||
msgPrinted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleOscar);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
|
||||
function createOscar(container) {
|
||||
// Red carpet floor
|
||||
const carpet = document.createElement('div');
|
||||
carpet.className = 'oscar-carpet';
|
||||
|
||||
// Spotlights
|
||||
const spotlights = document.createElement('div');
|
||||
spotlights.className = 'oscar-spotlights';
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const spot = document.createElement('div');
|
||||
spot.className = 'oscar-spotlight';
|
||||
spot.style.animationDelay = `-${Math.random() * 8}s`;
|
||||
spot.style.left = `${20 + (i * 30)}%`;
|
||||
spot.style.top = `${-5 - Math.random() * 15}vh`; // randomize top origin
|
||||
spotlights.appendChild(spot);
|
||||
}
|
||||
|
||||
container.appendChild(carpet);
|
||||
container.appendChild(spotlights);
|
||||
|
||||
// Paparazzi flashes with randomized intervals
|
||||
function flashLoop() {
|
||||
if (!document.querySelector('.oscar-container')) {
|
||||
setTimeout(flashLoop, 1000); // Check again later if hidden
|
||||
return;
|
||||
}
|
||||
const flash = document.createElement('div');
|
||||
flash.className = 'oscar-flash';
|
||||
flash.style.left = `${Math.random() * 100}%`;
|
||||
flash.style.top = `${Math.random() * 100}%`;
|
||||
container.appendChild(flash);
|
||||
setTimeout(() => flash.remove(), 200);
|
||||
|
||||
// Randomize next flash between 200ms and 1500ms
|
||||
const nextDelay = Math.random() * 1300 + 200;
|
||||
setTimeout(flashLoop, nextDelay);
|
||||
}
|
||||
flashLoop();
|
||||
}
|
||||
|
||||
function initializeOscar() {
|
||||
if (!oscar) return;
|
||||
|
||||
const container = document.querySelector('.oscar-container') || document.createElement("div");
|
||||
|
||||
if (!document.querySelector('.oscar-container')) {
|
||||
container.className = "oscar-container";
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
createOscar(container);
|
||||
toggleOscar();
|
||||
}
|
||||
|
||||
initializeOscar();
|
||||
33
Jellyfin.Plugin.Seasonals/Web/pride.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.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 */
|
||||
body.pride-active .skinHeader,
|
||||
body.pride-active .skinHeader-withBackground {
|
||||
background: linear-gradient(90deg, #E40303, #FF8C00, #FFED00, #008026, #24408E, #732982) !important;
|
||||
}
|
||||
92
Jellyfin.Plugin.Seasonals/Web/pride.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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) {
|
||||
document.body.classList.add('pride-active');
|
||||
}
|
||||
|
||||
const cleanupObserver = new MutationObserver(() => {
|
||||
if (!document.querySelector('.pride-container')) {
|
||||
document.body.classList.remove('pride-active');
|
||||
}
|
||||
});
|
||||
cleanupObserver.observe(document.body, { childList: true });
|
||||
|
||||
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(180vh) translateX(-60vh) rotate(20deg); opacity: 0; }
|
||||
}
|
||||
73
Jellyfin.Plugin.Seasonals/Web/rain.js
Normal file
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(toggleRain);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeRain() {
|
||||
if (!enabled) return;
|
||||
createElements();
|
||||
toggleRain();
|
||||
}
|
||||
|
||||
initializeRain();
|
||||
68
Jellyfin.Plugin.Seasonals/Web/resurrection.css
Normal file
@@ -0,0 +1,68 @@
|
||||
.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: 0;
|
||||
translate: 0 -15vh;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
animation-name: resurrection-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.resurrection-sway-wrapper {
|
||||
will-change: transform;
|
||||
animation-name: resurrection-sway;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.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% {
|
||||
transform: translate3d(0, -15vh, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate3d(0, 105vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes resurrection-sway {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(65px);
|
||||
}
|
||||
}
|
||||
120
Jellyfin.Plugin.Seasonals/Web/resurrection.js
Normal file
@@ -0,0 +1,120 @@
|
||||
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 swayWrapper = document.createElement('div');
|
||||
swayWrapper.className = 'resurrection-sway-wrapper';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = imageSrc;
|
||||
img.alt = '';
|
||||
|
||||
symbol.style.left = `${leftPercent}%`;
|
||||
symbol.style.animationDelay = `${delaySeconds}s`;
|
||||
|
||||
if (enableDifferentDuration) {
|
||||
const fallDuration = Math.random() * 7 + 7;
|
||||
const swayDuration = Math.random() * 4 + 2;
|
||||
symbol.style.animationDuration = `${fallDuration}s`;
|
||||
swayWrapper.style.animationDuration = `${swayDuration}s`;
|
||||
}
|
||||
|
||||
swayWrapper.style.animationDelay = `${Math.random() * 3}s`;
|
||||
|
||||
swayWrapper.appendChild(img);
|
||||
symbol.appendChild(swayWrapper);
|
||||
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 |