commit dcef6d0080647f8c93d210a1271fa36bbccf5fce Author: CodeDevMLH <145071728+CodeDevMLH@users.noreply.github.com> Date: Sun Dec 14 19:53:16 2025 +0100 init diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b84e563 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,194 @@ +# With more recent updates Visual Studio 2017 supports EditorConfig files out of the box +# Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode +# For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig +############################### +# Core EditorConfig Options # +############################### +root = true +# All files +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf +max_line_length = off + +# YAML indentation +[*.{yml,yaml}] +indent_size = 2 + +# XML indentation +[*.{csproj,xml}] +indent_size = 2 + +############################### +# .NET Coding Conventions # +############################### +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent + +############################### +# Naming Conventions # +############################### +# Style Definitions (From Roslyn) + +# Non-private static fields are PascalCase +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_static_fields.required_modifiers = static + +dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case + +# Constants are PascalCase +dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants +dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style + +dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.constant_style.capitalization = pascal_case + +# Static fields are camelCase and start with s_ +dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields +dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style + +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static + +dotnet_naming_style.static_field_style.capitalization = camel_case +dotnet_naming_style.static_field_style.required_prefix = _ + +# Instance fields are camelCase and start with _ +dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style + +dotnet_naming_symbols.instance_fields.applicable_kinds = field + +dotnet_naming_style.instance_field_style.capitalization = camel_case +dotnet_naming_style.instance_field_style.required_prefix = _ + +# Locals and parameters are camelCase +dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion +dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters +dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style + +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +dotnet_naming_style.local_function_style.capitalization = pascal_case + +# By default, name items with PascalCase +dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.all_members.applicable_kinds = * + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +############################### +# C# Coding Conventions # +############################### +[*.cs] +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +############################### +# C# Formatting Rules # +############################### +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b72c24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +.vs/ +.idea/ +artifacts diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..c702921 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + 0.0.0.0 + 0.0.0.0 + 0.0.0.0 + + diff --git a/Jellyfin.Plugin.Seasonals.sln b/Jellyfin.Plugin.Seasonals.sln new file mode 100644 index 0000000..e0a83c9 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals.sln @@ -0,0 +1,28 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Seasonals", "Jellyfin.Plugin.Seasonals\Jellyfin.Plugin.Seasonals.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.ActiveCfg = Debug|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.Build.0 = Debug|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x86.ActiveCfg = Debug|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x86.Build.0 = Debug|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.ActiveCfg = Release|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.Build.0 = Release|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.ActiveCfg = Release|Any CPU + {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Jellyfin.Plugin.Seasonals/Api/SeasonalsController.cs b/Jellyfin.Plugin.Seasonals/Api/SeasonalsController.cs new file mode 100644 index 0000000..4d4e7ea --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Api/SeasonalsController.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Plugin.Seasonals.Api; + +[ApiController] +[Route("Seasonals")] +public class SeasonalsController : ControllerBase +{ + [HttpGet("Config")] + [Produces("application/json")] + public ActionResult GetConfig() + { + var config = Plugin.Instance?.Configuration; + return new + { + selectedSeason = config?.SelectedSeason ?? "none", + automateSeasonSelection = config?.AutomateSeasonSelection ?? true + }; + } + + [HttpGet("Resources/{*path}")] + public ActionResult GetResource(string path) + { + // Sanitize path + if (string.IsNullOrWhiteSpace(path) || path.Contains("..")) + { + return BadRequest(); + } + + var assembly = Assembly.GetExecutingAssembly(); + // Convert path to resource name + // path: "autumn_images/acorn1.png" -> "Jellyfin.Plugin.Seasonals.Web.autumn_images.acorn1.png" + var resourcePath = path.Replace('/', '.').Replace('\\', '.'); + var resourceName = $"Jellyfin.Plugin.Seasonals.Web.{resourcePath}"; + + var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + return NotFound($"Resource not found: {resourceName}"); + } + + string contentType = GetContentType(path); + return File(stream, contentType); + } + + private string GetContentType(string path) + { + if (path.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) return "application/javascript"; + if (path.EndsWith(".css", StringComparison.OrdinalIgnoreCase)) return "text/css"; + 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"; + return "application/octet-stream"; + } +} diff --git a/Jellyfin.Plugin.Seasonals/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Seasonals/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..193aaed --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Configuration/PluginConfiguration.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.Seasonals.Configuration; + +/// +/// Plugin configuration. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public PluginConfiguration() + { + SelectedSeason = "none"; + AutomateSeasonSelection = true; + } + + /// + /// Gets or sets the selected season. + /// + public string SelectedSeason { get; set; } + + /// + /// Gets or sets a value indicating whether to automate season selection. + /// + public bool AutomateSeasonSelection { get; set; } +} diff --git a/Jellyfin.Plugin.Seasonals/Configuration/configPage.html b/Jellyfin.Plugin.Seasonals/Configuration/configPage.html new file mode 100644 index 0000000..ef9739b --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Configuration/configPage.html @@ -0,0 +1,76 @@ + + + + + Template + + +
+
+
+
+
+ +
Automatically select the season based on the date.
+
+
+ + +
The season to display if automation is disabled.
+
+
+ +
+
+
+
+ +
+ + diff --git a/Jellyfin.Plugin.Seasonals/Jellyfin.Plugin.Seasonals.csproj b/Jellyfin.Plugin.Seasonals/Jellyfin.Plugin.Seasonals.csproj new file mode 100644 index 0000000..41a19e2 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Jellyfin.Plugin.Seasonals.csproj @@ -0,0 +1,35 @@ + + + + net9.0 + Jellyfin.Plugin.Seasonals + true + false + enable + AllEnabledByDefault + ../jellyfin.ruleset + + + + + runtime + + + runtime + + + + + + + + + + + + + + + + + diff --git a/Jellyfin.Plugin.Seasonals/Plugin.cs b/Jellyfin.Plugin.Seasonals/Plugin.cs new file mode 100644 index 0000000..47601b6 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Plugin.cs @@ -0,0 +1,57 @@ +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; + +/// +/// The main plugin. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + private readonly ScriptInjector _scriptInjector; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILoggerFactory loggerFactory) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + _scriptInjector = new ScriptInjector(applicationPaths, loggerFactory.CreateLogger()); + _scriptInjector.Inject(); + } + + /// + public override string Name => "Seasonals"; + + /// + public override Guid Id => Guid.Parse("ef1e863f-cbb0-4e47-9f23-f0cbb1826ad4"); + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable GetPages() + { + return + [ + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace) + } + ]; + } +} diff --git a/Jellyfin.Plugin.Seasonals/ScriptInjector.cs b/Jellyfin.Plugin.Seasonals/ScriptInjector.cs new file mode 100644 index 0000000..cb0b363 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/ScriptInjector.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.Extensions.Logging; +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Plugin.Seasonals; + +public class ScriptInjector +{ + private readonly IApplicationPaths _appPaths; + private readonly ILogger _logger; + private const string ScriptTag = ""; + private const string Marker = ""; + + public ScriptInjector(IApplicationPaths appPaths, ILogger logger) + { + _appPaths = appPaths; + _logger = logger; + } + + public void Inject() + { + try + { + var webPath = GetWebPath(); + if (string.IsNullOrEmpty(webPath)) + { + _logger.LogWarning("Could not find Jellyfin web path. Script injection skipped."); + 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; + } + + var content = File.ReadAllText(indexPath); + if (content.Contains(ScriptTag)) + { + _logger.LogInformation("Seasonals script already injected."); + return; + } + + var newContent = content.Replace(Marker, $"{ScriptTag}\n{Marker}"); + if (newContent == content) + { + _logger.LogWarning("Could not find closing body tag in index.html. Script injection skipped."); + return; + } + + File.WriteAllText(indexPath, newContent); + _logger.LogInformation("Successfully injected Seasonals script into index.html."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error injecting Seasonals script."); + } + } + + public void Remove() + { + 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)) return; + + var newContent = content.Replace(ScriptTag, "").Replace($"{ScriptTag}\n", ""); + File.WriteAllText(indexPath, newContent); + _logger.LogInformation("Successfully removed Seasonals script from index.html."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing Seasonals script."); + } + } + + private string? GetWebPath() + { + // Try to find the web path using IApplicationPaths + // Note: The property name might vary depending on the Jellyfin version, + // but usually it's accessible via the configuration or standard paths. + // For this plugin context, we'll try to rely on the standard path structure if IApplicationPaths doesn't expose it directly + // or if we need to infer it. + + // In many Jellyfin versions, appPaths.WebPath is the property. + // However, since we are in a plugin, we might need to use reflection if the interface doesn't expose it in the reference assembly, + // or just assume it's there. + + // Let's try to use the property if it exists. + // If the compilation fails, we will adjust. + // For now, we assume 'WebPath' is available on IApplicationPaths implementation + // but it might not be on the interface in the nuget package. + + // Workaround: Check known locations if property is missing or use reflection. + + // Attempt 1: Reflection to get WebPath property (safest if interface varies) + var prop = _appPaths.GetType().GetProperty("WebPath"); + if (prop != null) + { + return prop.GetValue(_appPaths) as string; + } + + // Attempt 2: Guess based on ProgramDataPath (common in Windows) + // Usually /jellyfin/web or /jellyfin-web + + // Let's try to look relative to the application executable if possible, but that's hard from a plugin. + + return null; + } +} diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn.css b/Jellyfin.Plugin.Seasonals/Web/autumn.css new file mode 100644 index 0000000..129d5bb --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/autumn.css @@ -0,0 +1,155 @@ +.autumn-container { + display: block; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.leaf { + 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: leaf-fall, leaf-shake; + -webkit-animation-duration: 7s, 4s; + -webkit-animation-timing-function: linear, ease-in-out; + -webkit-animation-iteration-count: infinite, infinite; + -webkit-user-select: none; + animation-name: leaf-fall, leaf-shake; + animation-duration: 7s, 4s; + animation-timing-function: linear, ease-in-out; + animation-iteration-count: infinite, infinite; +} + +/* Class to disable rotation */ +.no-rotation { + --rotate-start: 0deg !important; + --rotate-end: 0deg !important; +} + +@-webkit-keyframes leaf-fall { + 0% { + top: -10%; + } + + 100% { + top: 100%; + } +} + +@keyframes leaf-fall { + 0% { + top: -10%; + } + + 100% { + top: 100%; + } +} + +@-webkit-keyframes leaf-shake { + 0%, 100% { + -webkit-transform: translateX(0) rotate(var(--rotate-start, -20deg)); + } + 50% { + -webkit-transform: translateX(80px) rotate(var(--rotate-end, 20deg)); + } +} + +@keyframes leaf-shake { + 0%, 100% { + transform: translateX(0) rotate(var(--rotate-start, -20deg)); + } + 50% { + transform: translateX(80px) rotate(var(--rotate-end, 20deg)); + } +} + +.leaf:nth-of-type(0) { + left: 0%; + animation-delay: 0s, 0s; + --rotate-start: -25deg; + --rotate-end: 22deg; +} + +.leaf:nth-of-type(1) { + left: 10%; + animation-delay: 1s, 0.5s; + --rotate-start: -32deg; + --rotate-end: 35deg; +} + +.leaf:nth-of-type(2) { + left: 20%; + animation-delay: 6s, 1s; + --rotate-start: -28deg; + --rotate-end: 28deg; +} + +.leaf:nth-of-type(3) { + left: 30%; + animation-delay: 4s, 1.5s; + --rotate-start: -38deg; + --rotate-end: 32deg; +} + +.leaf:nth-of-type(4) { + left: 40%; + animation-delay: 2s, 0.8s; + --rotate-start: -22deg; + --rotate-end: 38deg; +} + +.leaf:nth-of-type(5) { + left: 50%; + animation-delay: 8s, 2s; + --rotate-start: -35deg; + --rotate-end: 25deg; +} + +.leaf:nth-of-type(6) { + left: 60%; + animation-delay: 6s, 1.2s; + --rotate-start: -40deg; + --rotate-end: 40deg; +} + +.leaf:nth-of-type(7) { + left: 70%; + animation-delay: 2.5s, 0.3s; + --rotate-start: -30deg; + --rotate-end: 30deg; +} + +.leaf:nth-of-type(8) { + left: 80%; + animation-delay: 1s, 1.8s; + --rotate-start: -26deg; + --rotate-end: 36deg; +} + +.leaf:nth-of-type(9) { + left: 90%; + animation-delay: 3s, 0.7s; + --rotate-start: -34deg; + --rotate-end: 24deg; +} + +.leaf:nth-of-type(10) { + left: 25%; + animation-delay: 2s, 2.3s; + --rotate-start: -29deg; + --rotate-end: 33deg; +} + +.leaf:nth-of-type(11) { + left: 65%; + animation-delay: 4s, 1.4s; + --rotate-start: -37deg; + --rotate-end: 27deg; +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn.js b/Jellyfin.Plugin.Seasonals/Web/autumn.js new file mode 100644 index 0000000..336dbc6 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/autumn.js @@ -0,0 +1,175 @@ +const leaves = true; // enable/disable leaves +const randomLeaves = true; // enable random leaves +const randomLeavesMobile = false; // enable random leaves on mobile devices +const enableDiffrentDuration = true; // enable different duration for the random leaves +const enableRotation = false; // enable/disable leaf rotation +const leafCount = 25; // count of random extra leaves + + +let msgPrinted = false; // flag to prevent multiple console messages + +// function to check and control the leaves +function toggleAutumn() { + const autumnContainer = document.querySelector('.autumn-container'); + if (!autumnContainer) 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 leaves if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + autumnContainer.style.display = 'none'; // hide leaves + if (!msgPrinted) { + console.log('Autumn hidden'); + msgPrinted = true; + } + } else { + autumnContainer.style.display = 'block'; // show leaves + if (msgPrinted) { + console.log('Autumn visible'); + msgPrinted = false; + } + } +} + +// 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) +}); + + +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 + + console.log('Adding random leaves'); + + // Array of leave characters + for (let i = 0; i < count; i++) { + // create a new leave element + const leaveDiv = document.createElement('div'); + leaveDiv.className = enableRotation ? "leaf" : "leaf no-rotation"; + + // pick a random leaf symbol + const imageSrc = images[Math.floor(Math.random() * images.length)]; + const img = document.createElement("img"); + img.src = imageSrc; + + leaveDiv.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 for fall (0s to 12s) + const randomAnimationDelay2 = Math.random() * 4; // delay for shake+rotate (0s to 4s) + + // apply styles + leaveDiv.style.left = `${randomLeft}%`; + leaveDiv.style.animationDelay = `${randomAnimationDelay}s, ${randomAnimationDelay2}s`; + + // set random animation duration + if (enableDiffrentDuration) { + const randomAnimationDuration = Math.random() * 10 + 6; // fall duration (6s to 16s) + const randomAnimationDuration2 = Math.random() * 3 + 2; // shake+rotate duration (2s to 5s) + leaveDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + // set random rotation angles (only if rotation is enabled) + if (enableRotation) { + const randomRotateStart = -(Math.random() * 40 + 20); // -20deg to -60deg + const randomRotateEnd = Math.random() * 40 + 20; // 20deg to 60deg + leaveDiv.style.setProperty('--rotate-start', `${randomRotateStart}deg`); + leaveDiv.style.setProperty('--rotate-end', `${randomRotateEnd}deg`); + } else { + // No rotation - set to 0 degrees + leaveDiv.style.setProperty('--rotate-start', '0deg'); + leaveDiv.style.setProperty('--rotate-end', '0deg'); + } + + // add the leave to the container + autumnContainer.appendChild(leaveDiv); + } + console.log('Random leaves added'); +} + +// initialize standard leaves +function initLeaves() { + const container = document.querySelector('.autumn-container') || document.createElement("div"); + + if (!document.querySelector('.autumn-container')) { + container.className = "autumn-container"; + container.setAttribute("aria-hidden", "true"); + document.body.appendChild(container); + } + + for (let i = 0; i < 12; i++) { + const leafDiv = document.createElement("div"); + leafDiv.className = enableRotation ? "leaf" : "leaf no-rotation"; + + const img = document.createElement("img"); + img.src = images[Math.floor(Math.random() * images.length)]; + + // set random animation duration + if (enableDiffrentDuration) { + const randomAnimationDuration = Math.random() * 10 + 6; // fall duration (6s to 16s) + const randomAnimationDuration2 = Math.random() * 3 + 2; // shake+rotate duration (2s to 5s) + leafDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + // set random rotation angles for standard leaves too (only if rotation is enabled) + if (enableRotation) { + const randomRotateStart = -(Math.random() * 40 + 20); // -20deg to -60deg + const randomRotateEnd = Math.random() * 40 + 20; // 20deg to 60deg + leafDiv.style.setProperty('--rotate-start', `${randomRotateStart}deg`); + leafDiv.style.setProperty('--rotate-end', `${randomRotateEnd}deg`); + } else { + // No rotation - set to 0 degrees + leafDiv.style.setProperty('--rotate-start', '0deg'); + leafDiv.style.setProperty('--rotate-end', '0deg'); + } + + leafDiv.appendChild(img); + container.appendChild(leafDiv); + } +} + +// initialize leaves and add random leaves +function initializeLeaves() { + if (!leaves) return; // exit if leaves are disabled + initLeaves(); + toggleAutumn(); + + const screenWidth = window.innerWidth; // get the screen width to detect mobile devices + if (randomLeaves && (screenWidth > 768 || randomLeavesMobile)) { // add random leaves only on larger screens, unless enabled for mobile devices + addRandomLeaves(leafCount); + } +} + +initializeLeaves(); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/acorn1.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/acorn1.png new file mode 100644 index 0000000..c07e2cf Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/acorn1.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/acorn2.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/acorn2.png new file mode 100644 index 0000000..226e779 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/acorn2.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf1.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf1.png new file mode 100644 index 0000000..49c1562 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf1.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf10.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf10.png new file mode 100644 index 0000000..d6b35e8 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf10.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf11.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf11.png new file mode 100644 index 0000000..a737474 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf11.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf12.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf12.png new file mode 100644 index 0000000..6d826f5 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf12.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf13.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf13.png new file mode 100644 index 0000000..606eca0 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf13.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf14.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf14.png new file mode 100644 index 0000000..efcb182 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf14.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf15.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf15.png new file mode 100644 index 0000000..85c5b81 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf15.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf2.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf2.png new file mode 100644 index 0000000..5803e96 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf2.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf3.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf3.png new file mode 100644 index 0000000..8562965 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf3.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf4.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf4.png new file mode 100644 index 0000000..54b9aa7 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf4.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf5.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf5.png new file mode 100644 index 0000000..2cd8e22 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf5.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf6.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf6.png new file mode 100644 index 0000000..154843f Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf6.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf7.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf7.png new file mode 100644 index 0000000..7b89b00 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf7.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf8.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf8.png new file mode 100644 index 0000000..60ac740 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf8.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf9.png b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf9.png new file mode 100644 index 0000000..f59a39e Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/autumn_images/leaf9.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/christmas.css b/Jellyfin.Plugin.Seasonals/Web/christmas.css new file mode 100644 index 0000000..0b4a1cc --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/christmas.css @@ -0,0 +1,132 @@ +.christmas-container { + display: block; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.christmas { + position: fixed; + top: -10%; + font-size: 1em; + color: #fff; + font-family: Arial, sans-serif; + text-shadow: 0 0 5px #000; + user-select: none; + cursor: default; + -webkit-user-select: none; + -webkit-animation-name: christmas-fall, christmas-shake; + -webkit-animation-duration: 10s, 3s; + -webkit-animation-timing-function: linear, ease-in-out; + -webkit-animation-iteration-count: infinite, infinite; + animation-name: christmas-fall, christmas-shake; + animation-duration: 10s, 3s; + animation-timing-function: linear, ease-in-out; + animation-iteration-count: infinite, infinite; +} + +@-webkit-keyframes christmas-fall { + 0% { + top: -10%; + } + + 100% { + top: 100%; + } +} + +@-webkit-keyframes christmas-shake { + + 0%, + 100% { + -webkit-transform: translateX(0); + transform: translateX(0); + } + + 50% { + -webkit-transform: translateX(80px); + transform: translateX(80px); + } +} + +@keyframes christmas-fall { + 0% { + top: -10%; + } + + 100% { + top: 100%; + } +} + +@keyframes christmas-shake { + + 0%, + 100% { + transform: translateX(0); + } + + 50% { + transform: translateX(80px); + } +} + +.christmas:nth-of-type(0) { + left: 0%; + animation-delay: 0s, 0s; +} + +.christmas:nth-of-type(1) { + left: 10%; + animation-delay: 1s, 1s; +} + +.christmas:nth-of-type(2) { + left: 20%; + animation-delay: 6s, 0.5s; +} + +.christmas:nth-of-type(3) { + left: 30%; + animation-delay: 4s, 2s; +} + +.christmas:nth-of-type(4) { + left: 40%; + animation-delay: 2s, 2s; +} + +.christmas:nth-of-type(5) { + left: 50%; + animation-delay: 8s, 3s; +} + +.christmas:nth-of-type(6) { + left: 60%; + animation-delay: 6s, 2s; +} + +.christmas:nth-of-type(7) { + left: 70%; + animation-delay: 2.5s, 1s; +} + +.christmas:nth-of-type(8) { + left: 80%; + animation-delay: 1s, 0s; +} + +.christmas:nth-of-type(9) { + left: 90%; + animation-delay: 3s, 1.5s; +} + +.christmas:nth-of-type(10) { + left: 25%; + animation-delay: 2s, 0s; +} + +.christmas:nth-of-type(11) { + left: 65%; + animation-delay: 4s, 2.5s; +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/christmas.js b/Jellyfin.Plugin.Seasonals/Web/christmas.js new file mode 100644 index 0000000..732ebc6 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/christmas.js @@ -0,0 +1,124 @@ +const christmas = true; // enable/disable christmas +const randomChristmas = true; // enable random Christmas +const randomChristmasMobile = false; // enable random Christmas on mobile devices +const enableDiffrentDuration = true; // enable different duration for the random Christmas symbols +const christmasCount = 25; // count of random extra christmas + + +let msgPrinted = false; // flag to prevent multiple console messages + +// function to check and control the christmas +function toggleChristmas() { + const christmasContainer = document.querySelector('.christmas-container'); + if (!christmasContainer) 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 christmas if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + christmasContainer.style.display = 'none'; // hide christmas + if (!msgPrinted) { + console.log('Christmas hidden'); + msgPrinted = true; + } + } else { + christmasContainer.style.display = 'block'; // show christmas + if (msgPrinted) { + console.log('Christmas visible'); + msgPrinted = false; + } + } +} + +// 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) +}); + +// 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 + + console.log('Adding random christmas'); + + for (let i = 0; i < count; i++) { + // create a new christmas element + const christmasDiv = document.createElement('div'); + christmasDiv.classList.add('christmas'); + + // pick a random christmas symbol + christmasDiv.textContent = christmasSymbols[Math.floor(Math.random() * christmasSymbols.length)]; + + // 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 + 8; // delay (8s to 12s) + const randomAnimationDelay2 = Math.random() * 5 + 3; // delay (0s to 5s) + + // apply styles + christmasDiv.style.left = `${randomLeft}%`; + christmasDiv.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) + christmasDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + // add the christmas to the container + christmasContainer.appendChild(christmasDiv); + } + console.log('Random christmas added'); +} + +// initialize standard christmas +function initChristmas() { + const christmasContainer = document.querySelector('.christmas-container') || document.createElement("div"); + + if (!document.querySelector('.christmas-container')) { + christmasContainer.className = "christmas-container"; + christmasContainer.setAttribute("aria-hidden", "true"); + document.body.appendChild(christmasContainer); + } + + // create the 12 standard christmas + for (let i = 0; i < 12; i++) { + const christmasDiv = document.createElement('div'); + christmasDiv.className = 'christmas'; + christmasDiv.textContent = christmasSymbols[Math.floor(Math.random() * christmasSymbols.length)]; + + // 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) + christmasDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + christmasContainer.appendChild(christmasDiv); + } +} + +// initialize christmas and add random christmas symbols +function initializeChristmas() { + if (!christmas) return; // exit if christmas is disabled + initChristmas(); + toggleChristmas(); + + const screenWidth = window.innerWidth; // get the screen width to detect mobile devices + if (randomChristmas && (screenWidth > 768 || randomChristmasMobile)) { // add random christmas only on larger screens, unless enabled for mobile devices + addRandomChristmas(christmasCount); + } +} + +initializeChristmas(); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/easter.css b/Jellyfin.Plugin.Seasonals/Web/easter.css new file mode 100644 index 0000000..82c29df --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/easter.css @@ -0,0 +1,152 @@ +.easter-container { + display: block; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.hopping-rabbit { + position: fixed; + bottom: 10px; + width: 70px; + overflow: hidden; + pointer-events: none; +} + + +@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; +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/easter.js b/Jellyfin.Plugin.Seasonals/Web/easter.js new file mode 100644 index 0000000..b39ac19 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/easter.js @@ -0,0 +1,241 @@ +const easter = true; // enable/disable easter +const randomEaster = true; // enable random easter +const randomEasterMobile = false; // enable random easter on mobile devices +const enableDiffrentDuration = true; // enable different duration for the random easter +const easterEggCount = 20; // count of random extra easter + +const bunny = true; // enable/disable hopping bunny +const bunnyDuration = 12000; // duration of the bunny animation in ms +const hopHeight = 12; // height of the bunny hops in px +const minBunnyRestTime = 2000; // minimum time the bunny rests in ms +const maxBunnyRestTime = 5000; // maximum time the bunny rests in ms + + +let msgPrinted = false; // flag to prevent multiple console messages +let animationFrameId; + +// function to check and control the easter +function toggleEaster() { + const easterContainer = document.querySelector('.easter-container'); + if (!easterContainer) 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 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; + } + if (!msgPrinted) { + console.log('Easter hidden'); + msgPrinted = true; + } + } else { + easterContainer.style.display = 'block'; // show easter + if (!animationFrameId) { + animateRabbit(); // start animation + } + if (msgPrinted) { + console.log('Easter visible'); + msgPrinted = false; + } + } +} + +// 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) +}); + + +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); + } + console.log('Random easter added'); +} + +function addHoppingRabbit() { + if (!bunny) return; // Nur ausfΓΌhren, wenn Easter aktiviert ist + + const easterContainer = document.querySelector('.easter-container'); + if (!easterContainer) return; + + // Hase erstellen + const rabbitImg = document.createElement("img"); + rabbitImg.id = "rabbit"; + rabbitImg.src = rabbit; // Bildpfad aus der bestehenden Definition + rabbitImg.alt = "Hoppelnder Osterhase"; + + // CSS-Klassen hinzufΓΌgen + rabbitImg.classList.add("hopping-rabbit"); + + easterContainer.appendChild(rabbitImg); + + rabbitImg.style.bottom = (hopHeight / 2 + 6) + "px"; + + animateRabbit(rabbitImg); +} + +function animateRabbit(rabbitElement) { + const rabbit = rabbitElement || document.querySelector('#rabbit'); + if (!rabbit) return; + + let startTime = null; + + function animationStep(timestamp) { + 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)'; + } + const progress = timestamp - startTime; + + // calculate the horizontal position (linear interpolation) + const x = rabbit.startX + (progress / bunnyDuration) * (rabbit.endX - rabbit.startX); + + // 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); + } 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); + } + } + + animationFrameId = requestAnimationFrame(animationStep); +} + + +// initialize standard easter +function initEaster() { + const container = document.querySelector('.easter-container') || document.createElement("div"); + + if (!document.querySelector('.easter-container')) { + container.className = "easter-container"; + container.setAttribute("aria-hidden", "true"); + 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`; + } + + 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(); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/easter-bunny.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/easter-bunny.png new file mode 100644 index 0000000..05b5467 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/easter-bunny.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_1.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_1.png new file mode 100644 index 0000000..3740809 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_1.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_10.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_10.png new file mode 100644 index 0000000..1325c06 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_10.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_11.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_11.png new file mode 100644 index 0000000..3727ace Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_11.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_12.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_12.png new file mode 100644 index 0000000..f0c7f75 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_12.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_2.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_2.png new file mode 100644 index 0000000..96c88b4 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_2.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_3.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_3.png new file mode 100644 index 0000000..8f4bcf4 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_3.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_4.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_4.png new file mode 100644 index 0000000..d3cba20 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_4.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_5.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_5.png new file mode 100644 index 0000000..a1add51 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_5.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_6.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_6.png new file mode 100644 index 0000000..a24a861 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_6.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_7.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_7.png new file mode 100644 index 0000000..182832e Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_7.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_8.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_8.png new file mode 100644 index 0000000..b7cad4c Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_8.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_9.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_9.png new file mode 100644 index 0000000..7daafbb Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/egg_9.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/eggs.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/eggs.png new file mode 100644 index 0000000..aba5340 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/eggs.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/rabbit_1.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/rabbit_1.png new file mode 100644 index 0000000..9386a81 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/rabbit_1.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/easter_images/rabbit_2.png b/Jellyfin.Plugin.Seasonals/Web/easter_images/rabbit_2.png new file mode 100644 index 0000000..369b835 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/easter_images/rabbit_2.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/fireworks.css b/Jellyfin.Plugin.Seasonals/Web/fireworks.css new file mode 100644 index 0000000..aded202 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/fireworks.css @@ -0,0 +1,63 @@ +.fireworks { + position: fixed; + overflow: hidden; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 10; +} + +.rocket-trail { + position: absolute; + left: var(--trailX); + top: var(--trailStartY); + width: 4px; + + /* activate the following for rocket trail */ + height: 60px; + background: linear-gradient(to bottom, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); + filter: blur(2px); + + /* activate the following for rocket trail as a point */ + /*height: 4px;*/ + /*background: white;*/ + /*border-radius: 50%; + box-shadow: 0 0 8px 2px white;*/ + + animation: rocket-trail-animation 1s linear forwards; +} + +@keyframes rocket-trail-animation { + 0% { + transform: translateY(0); + opacity: 1; + } + 100% { + transform: translateY(calc(var(--trailEndY) - var(--trailStartY))); + opacity: 0; + } +} + +/* Animation for the particles */ +@keyframes fireworkParticle { + 0% { + opacity: 1; + transform: translate(0, 0); + } + 100% { + opacity: 0; + transform: translate(var(--x), var(--y)); + } +} + +.firework { + position: absolute; + width: 5px; + height: 5px; + background: white; + border-radius: 50%; + animation: fireworkParticle 1.5s ease-out forwards; + filter: blur(1px); +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/fireworks.js b/Jellyfin.Plugin.Seasonals/Web/fireworks.js new file mode 100644 index 0000000..15332cd --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/fireworks.js @@ -0,0 +1,161 @@ +const fireworks = true; // enable/disable fireworks +const scrollFireworks = true; // enable fireworks to scroll with page content +const particlesPerFirework = 50; // count of particles per firework +const minFireworks = 3; // minimum number of simultaneous fireworks +const maxFireworks = 6; // maximum number of simultaneous fireworks +const intervalOfFireworks = 3200; // interval for the fireworks in milliseconds + +// array of color palettes for the fireworks +const colorPalettes = [ + ['#ff0000', '#ff7300', '#ff4500'], // red's + ['#0040ff', '#5a9bff', '#b0d9ff'], // blue's + ['#47ff00', '#8eff47', '#00ff7f'], // green's + ['#ffd700', '#c0c0c0', '#ff6347'], // gold, silver, red + ['#ff00ff', '#ff99ff', '#800080'], // magenta's + ['#ffef00', '#ffff99', '#ffd700'], // yellow's + ['#ff4500', '#ff6347', '#ff7f50'], // orange's + ['#e3e3e3', '#c0c0c0', '#7d7c7c'], // silver's +]; + +let msgPrinted = false; // flag to prevent multiple console messages +let spacing = 0; // spacing between fireworks + +// function to check and control fireworks +function toggleFirework() { + const fireworksContainer = document.querySelector('.fireworks'); + if (!fireworksContainer) 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 fireworks if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + fireworksContainer.style.display = 'none'; // hide fireworks + if (!msgPrinted) { + console.log('Fireworks hidden'); + clearInterval(fireworksInterval); + msgPrinted = true; + } + } else { + fireworksContainer.style.display = 'block'; // show fireworks + if (msgPrinted) { + console.log('Fireworks visible'); + startFireworks(); + msgPrinted = false; + } + } +} + +// observe changes in the DOM +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) +}); + + +// Function to create a rocket trail +function createRocketTrail(x, startY, endY) { + const fireworkContainer = document.querySelector('.fireworks'); + const rocketTrail = document.createElement('div'); + rocketTrail.classList.add('rocket-trail'); + fireworkContainer.appendChild(rocketTrail); + + // Set position and animation + rocketTrail.style.setProperty('--trailX', `${x}px`); + rocketTrail.style.setProperty('--trailStartY', `${startY}px`); + rocketTrail.style.setProperty('--trailEndY', `${endY}px`); + + // Remove the element after the animation + setTimeout(() => { + fireworkContainer.removeChild(rocketTrail); + }, 2000); // Duration of the animation +} + +// Function for particle explosion +function createExplosion(x, y) { + const fireworkContainer = document.querySelector('.fireworks'); + + // Choose a random color palette + const chosenPalette = colorPalettes[Math.floor(Math.random() * colorPalettes.length)]; + + for (let i = 0; i < particlesPerFirework; i++) { + const particle = document.createElement('div'); + particle.classList.add('firework'); + + const angle = Math.random() * 2 * Math.PI; // Random direction + const distance = Math.random() * 180 + 100; // Random distance + const xOffset = Math.cos(angle) * distance; + const yOffset = Math.sin(angle) * distance; + + particle.style.left = `${x}px`; + particle.style.top = `${y}px`; + particle.style.setProperty('--x', `${xOffset}px`); + particle.style.setProperty('--y', `${yOffset}px`); + particle.style.background = chosenPalette[Math.floor(Math.random() * chosenPalette.length)]; + + fireworkContainer.appendChild(particle); + + // Remove particle after the animation + setTimeout(() => particle.remove(), 3000); + } +} + +// Function for the firework with trail +function launchFirework() { + // Random horizontal position + const x = Math.random() * window.innerWidth; // Any value across the entire width + + // Trail starts at the bottom and ends at a random height around the middle + 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 + } else { + startY = window.innerHeight; // Bottom edge of the window + endY = Math.random() * window.innerHeight * 0.5 + window.innerHeight * 0.2; // Area around the middle + } + + // Create trail + createRocketTrail(x, startY, endY); + + // Create explosion + setTimeout(() => { + createExplosion(x, endY); // Explosion at the end height + }, 1000); // or 1200 +} + +// Start the firework routine +function startFireworks() { + const fireworkContainer = document.querySelector('.fireworks') || document.createElement("div"); + + if (!document.querySelector('.fireworks')) { + fireworkContainer.className = "fireworks"; + fireworkContainer.setAttribute("aria-hidden", "true"); + document.body.appendChild(fireworkContainer); + } + + fireworksInterval = setInterval(() => { + const randomCount = Math.floor(Math.random() * maxFireworks) + minFireworks; + for (let i = 0; i < randomCount; i++) { + setTimeout(() => { + launchFirework(); + }, i * 200); // 200ms delay between fireworks + } + }, intervalOfFireworks); // Interval between fireworks +} + +// Initialize fireworks and add random fireworks +function initializeFireworks() { + if (!fireworks) return; // exit if fireworks are disabled + startFireworks(); + toggleFirework(); +} + +initializeFireworks(); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/halloween.css b/Jellyfin.Plugin.Seasonals/Web/halloween.css new file mode 100644 index 0000000..3239b38 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/halloween.css @@ -0,0 +1,146 @@ +.halloween-container { + display: block; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.halloween { + position: fixed; + bottom: -10%; + z-index: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-select: none; + cursor: default; + -webkit-animation-name: halloween-fall, halloween-shake; + -webkit-animation-duration: 10s, 3s; + -webkit-animation-timing-function: linear, ease-in-out; + -webkit-animation-iteration-count: infinite, infinite; + -webkit-animation-play-state: running, running; + animation-name: halloween-fall, halloween-shake; + animation-duration: 10s, 3s; + animation-timing-function: linear, ease-in-out; + animation-iteration-count: infinite, infinite; + animation-play-state: running, running +} + +@-webkit-keyframes halloween-fall { + 0% { + bottom: -10% + } + + 100% { + bottom: 100% + } +} + +@-webkit-keyframes halloween-shake { + + 0%, + 100% { + -webkit-transform: translateX(0); + transform: translateX(0) + } + + 50% { + -webkit-transform: translateX(80px); + transform: translateX(80px) + } +} + +@keyframes halloween-fall { + 0% { + bottom: -10% + } + + 100% { + bottom: 100% + } +} + +@keyframes halloween-shake { + + 0%, + 100% { + transform: translateX(0) + } + + 50% { + transform: translateX(80px) + } +} + +.halloween:nth-of-type(0) { + left: 1%; + -webkit-animation-delay: 0s, 0s; + animation-delay: 0s, 0s +} + +.halloween:nth-of-type(1) { + left: 10%; + -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 +} + +.halloween:nth-of-type(3) { + left: 30%; + -webkit-animation-delay: 4s, 2s; + animation-delay: 4s, 2s +} + +.halloween:nth-of-type(4) { + left: 40%; + -webkit-animation-delay: 2s, 2s; + animation-delay: 2s, 2s +} + +.halloween:nth-of-type(5) { + left: 50%; + -webkit-animation-delay: 8s, 3s; + animation-delay: 8s, 3s +} + +.halloween:nth-of-type(6) { + left: 60%; + -webkit-animation-delay: 6s, 2s; + animation-delay: 6s, 2s +} + +.halloween:nth-of-type(7) { + left: 70%; + -webkit-animation-delay: 2.5s, 1s; + animation-delay: 2.5s, 1s +} + +.halloween:nth-of-type(8) { + left: 80%; + -webkit-animation-delay: 1s, 0s; + animation-delay: 1s, 0s +} + +.halloween:nth-of-type(9) { + left: 90%; + -webkit-animation-delay: 3s, 1.5s; + animation-delay: 3s, 1.5s +} + +.halloween:nth-of-type(10) { + left: 25%; + -webkit-animation-delay: 2s, 0s; + animation-delay: 2s, 0s +} + +.halloween:nth-of-type(11) { + left: 65%; + -webkit-animation-delay: 4s, 2.5s; + animation-delay: 4s, 2.5s +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/halloween.js b/Jellyfin.Plugin.Seasonals/Web/halloween.js new file mode 100644 index 0000000..19fb816 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/halloween.js @@ -0,0 +1,137 @@ +const halloween = true; // enable/disable halloween +const randomSymbols = true; // enable more random symbols +const randomSymbolsMobile = false; // enable random symbols on mobile devices +const enableDiffrentDuration = true; // enable different duration for the random halloween symbols +const halloweenCount = 25; // count of random extra symbols + +let msgPrinted = false; // flag to prevent multiple console messages + +// function to check and control the halloween +function toggleHalloween() { + const halloweenContainer = document.querySelector('.halloween-container'); + if (!halloweenContainer) 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 halloween if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + halloweenContainer.style.display = 'none'; // hide halloween + if (!msgPrinted) { + console.log('Halloween hidden'); + msgPrinted = true; + } + } else { + halloweenContainer.style.display = 'block'; // show halloween + if (msgPrinted) { + console.log('Halloween visible'); + msgPrinted = false; + } + } +} + +// 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) +}); + + +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 + + console.log('Adding random halloween symbols'); + + + for (let i = 0; i < count; i++) { + // create a new halloween elements + const halloweenDiv = document.createElement("div"); + halloweenDiv.className = "halloween"; + + // pick a random halloween symbol + const imageSrc = images[Math.floor(Math.random() * images.length)]; + const img = document.createElement("img"); + img.src = imageSrc; + + halloweenDiv.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() * 10; // delay (0s to 10s) + const randomAnimationDelay2 = Math.random() * 3; // delay (0s to 3s) + + // apply styles + halloweenDiv.style.left = `${randomLeft}%`; + halloweenDiv.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) + halloweenDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + // add the halloween to the container + halloweenContainer.appendChild(halloweenDiv); + } + console.log('Random halloween symbols added'); +} + +// create halloween objects +function createHalloween() { + const container = document.querySelector('.halloween-container') || document.createElement("div"); + + if (!document.querySelector('.halloween-container')) { + container.className = "halloween-container"; + container.setAttribute("aria-hidden", "true"); + document.body.appendChild(container); + } + + for (let i = 0; i < 4; i++) { + images.forEach(imageSrc => { + const halloweenDiv = document.createElement("div"); + halloweenDiv.className = "halloween"; + + const img = document.createElement("img"); + img.src = imageSrc; + + // 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) + halloweenDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + halloweenDiv.appendChild(img); + container.appendChild(halloweenDiv); + }); + } +} + +// initialize halloween +function initializeHalloween() { + if (!halloween) return; // exit if halloween is disabled + createHalloween(); + toggleHalloween(); + + 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); + } +} + +initializeHalloween(); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/halloween_images/bat_20x20.png b/Jellyfin.Plugin.Seasonals/Web/halloween_images/bat_20x20.png new file mode 100644 index 0000000..4ed37d5 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/halloween_images/bat_20x20.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/halloween_images/ghost_20x20.png b/Jellyfin.Plugin.Seasonals/Web/halloween_images/ghost_20x20.png new file mode 100644 index 0000000..18c7dc4 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/halloween_images/ghost_20x20.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/halloween_images/lep_30x30.png b/Jellyfin.Plugin.Seasonals/Web/halloween_images/lep_30x30.png new file mode 100644 index 0000000..26e95b7 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/halloween_images/lep_30x30.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/halloween_images/pumpkin_20x20.png b/Jellyfin.Plugin.Seasonals/Web/halloween_images/pumpkin_20x20.png new file mode 100644 index 0000000..46a77e7 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/halloween_images/pumpkin_20x20.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/hearts.css b/Jellyfin.Plugin.Seasonals/Web/hearts.css new file mode 100644 index 0000000..82b669d --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/hearts.css @@ -0,0 +1,144 @@ +.hearts-container { + display: block; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.heart { + position: fixed; + bottom: -10%; + z-index: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-select: none; + cursor: default; + -webkit-animation-name: heart-fall, heart-shake; + -webkit-animation-duration: 14s, 5s; + -webkit-animation-timing-function: linear, ease-in-out; + -webkit-animation-iteration-count: infinite, infinite; + animation-name: heart-fall, heart-shake; + animation-duration: 14s, 5s; + animation-timing-function: linear, ease-in-out; + animation-iteration-count: infinite, infinite; +} + +@-webkit-keyframes heart-fall { + 0% { + bottom: -10% + } + + 100% { + bottom: 100% + } +} + +@-webkit-keyframes heart-shake { + + 0%, + 100% { + -webkit-transform: translateX(0); + transform: translateX(0) + } + + 50% { + -webkit-transform: translateX(80px); + transform: translateX(80px) + } +} + +@keyframes heart-fall { + 0% { + bottom: -10% + } + + 100% { + bottom: 100% + } +} + +@keyframes heart-shake { + + 0%, + 100% { + transform: translateX(0) + } + + 50% { + transform: translateX(80px) + } +} + +.heart:nth-of-type(0) { + left: 1%; + -webkit-animation-delay: 0s, 0s; + animation-delay: 0s, 0s +} + +.heart:nth-of-type(1) { + left: 10%; + -webkit-animation-delay: 1s, 1s; + animation-delay: 1s, 1s +} + +.heart:nth-of-type(2) { + left: 20%; + -webkit-animation-delay: 6s, .5s; + animation-delay: 6s, .5s +} + +.heart:nth-of-type(3) { + left: 30%; + -webkit-animation-delay: 4s, 2s; + animation-delay: 4s, 2s +} + +.heart:nth-of-type(4) { + left: 40%; + -webkit-animation-delay: 2s, 2s; + animation-delay: 2s, 2s +} + +.heart:nth-of-type(5) { + left: 50%; + -webkit-animation-delay: 8s, 3s; + animation-delay: 8s, 3s +} + +.heart:nth-of-type(6) { + left: 60%; + -webkit-animation-delay: 6s, 2s; + animation-delay: 6s, 2s +} + +.heart:nth-of-type(7) { + left: 70%; + -webkit-animation-delay: 2.5s, 1s; + animation-delay: 2.5s, 1s +} + +.heart:nth-of-type(8) { + left: 80%; + -webkit-animation-delay: 1s, 0s; + animation-delay: 1s, 0s +} + +.heart:nth-of-type(9) { + left: 90%; + -webkit-animation-delay: 3s, 1.5s; + animation-delay: 3s, 1.5s +} + +.heart:nth-of-type(10) { + left: 25%; + -webkit-animation-delay: 2s, 0s; + animation-delay: 2s, 0s +} + +.heart:nth-of-type(11) { + left: 65%; + -webkit-animation-delay: 4s, 2.5s; + animation-delay: 4s, 2.5s +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/hearts.js b/Jellyfin.Plugin.Seasonals/Web/hearts.js new file mode 100644 index 0000000..db5b7a0 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/hearts.js @@ -0,0 +1,126 @@ +const hearts = true; // enable/disable hearts +const randomSymbols = true; // enable more random symbols +const randomSymbolsMobile = false; // enable random symbols on mobile devices +const enableDiffrentDuration = true; // enable different animation duration for random symbols +const heartsCount = 25; // count of random extra symbols + +let msgPrinted = false; // flag to prevent multiple console messages + +// function to check and control the hearts +function toggleHearts() { + const heartsContainer = document.querySelector('.hearts-container'); + if (!heartsContainer) 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 hearts if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + heartsContainer.style.display = 'none'; // hide hearts + if (!msgPrinted) { + console.log('Hearts hidden'); + msgPrinted = true; + } + } else { + heartsContainer.style.display = 'block'; // show hearts + if (msgPrinted) { + console.log('Hearts visible'); + msgPrinted = false; + } + } +} + +// 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) +}); + + +// 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 + + console.log('Adding random heart symbols'); + + for (let i = 0; i < count; i++) { + // create a new hearts elements + const heartsDiv = document.createElement("div"); + heartsDiv.className = "heart"; + + // pick a random hearts symbol + heartsDiv.textContent = heartSymbols[Math.floor(Math.random() * heartSymbols.length)]; + + + // 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() * 14; // delay (0s to 14s) + const randomAnimationDelay2 = Math.random() * 5; // delay (0s to 5s) + + // apply styles + heartsDiv.style.left = `${randomLeft}%`; + heartsDiv.style.animationDelay = `${randomAnimationDelay}s, ${randomAnimationDelay2}s`; + + // set random animation duration + if (enableDiffrentDuration) { + const randomAnimationDuration = Math.random() * 16 + 12; // delay (12s to 16s) + const randomAnimationDuration2 = Math.random() * 7 + 3; // delay (3s to 7s) + heartsDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + // add the hearts to the container + heartsContainer.appendChild(heartsDiv); + } + console.log('Random hearts symbols added'); +} + +// create hearts objects +function createHearts() { + const container = document.querySelector('.hearts-container') || document.createElement("div"); + + if (!document.querySelector('.hearts-container')) { + container.className = "hearts-container"; + container.setAttribute("aria-hidden", "true"); + document.body.appendChild(container); + } + + for (let i = 0; i < 12; i++) { + const heartsDiv = document.createElement("div"); + heartsDiv.className = "heart"; + heartsDiv.textContent = heartSymbols[i % heartSymbols.length]; + + // set random animation duration + if (enableDiffrentDuration) { + const randomAnimationDuration = Math.random() * 16 + 12; // delay (12s to 16s) + const randomAnimationDuration2 = Math.random() * 7 + 3; // delay (3s to 7s) + heartsDiv.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + container.appendChild(heartsDiv); + } +} + + +// initialize hearts +function initializeHearts() { + if (!hearts) return; // exit if hearts is disabled + createHearts(); + toggleHearts(); + + const screenWidth = window.innerWidth; // get the screen width to detect mobile devices + if (randomSymbols && (screenWidth > 768 || randomSymbolsMobile)) { // add random heartss only on larger screens, unless enabled for mobile devices + addRandomSymbols(heartsCount); + } +} + +initializeHearts(); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/santa.css b/Jellyfin.Plugin.Seasonals/Web/santa.css new file mode 100644 index 0000000..a89cf75 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/santa.css @@ -0,0 +1,33 @@ +.santa-container { + position: fixed; + width: 100%; + height: 100vh; + background: transparent; + overflow: hidden; + pointer-events: none; +} + +#snowfallCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.santa { + position: fixed; + width: 220px; + height: auto; + z-index: 1000; + pointer-events: none; +} + +.present { + position: fixed; + width: 15px; + height: auto; + z-index: 999; + pointer-events: none; +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/santa.js b/Jellyfin.Plugin.Seasonals/Web/santa.js new file mode 100644 index 0000000..89591aa --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/santa.js @@ -0,0 +1,303 @@ +const santaIsFlying = true; // enable/disable santa +let snowflakesCount = 500; // count of snowflakes (recommended values: 300-600) +const snowflakesCountMobile = 250; // count of snowflakes on mobile devices +const snowFallSpeed = 3; // speed of snowfall (recommended values: 0-5) +const santaSpeed = 10; // speed of santa in seconds (recommended values: 5000-15000) +const santaSpeedMobile = 8; // speed of santa on mobile devices in seconds +const maxSantaRestTime = 8; // maximum time santa rests in seconds +const minSantaRestTime = 3; // minimum time santa rests in seconds +const maxPresentFallSpeed = 5; // maximum speed of falling presents in seconds +const minPresentFallSpeed = 2; // minimum speed of falling presents in seconds + +let msgPrinted = false; // flag to prevent multiple console messages +let isMobile = false; // flag to detect mobile devices +let canvas, ctx; // canvas and context for drawing snowflakes +let animationFrameId; // ID of the animation frame +let animationFrameIdSanta; // ID of the animation frame for santa + +// function to check and control the santa +function toggleSnowfall() { + const santaContainer = document.querySelector('.santa-container'); + if (!santaContainer) 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 santa if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + santaContainer.style.display = 'none'; // hide santa + removeCanvas(); + if (!msgPrinted) { + console.log('Snowfall hidden'); + msgPrinted = true; + } + } else { + santaContainer.style.display = 'block'; // show santa + if (!animationFrameId && !animationFrameIdSanta) { + initializeCanvas(); + snowflakes = createSnowflakes(santaContainer); + animateAll(); + } + + if (msgPrinted) { + console.log('Snowfall visible'); + msgPrinted = false; + } + } +} + +// observe changes in the DOM +const observer = new MutationObserver(toggleSnowfall); + +// 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) +}); + + +function initializeCanvas() { + if (document.getElementById('snowfallCanvas')) { + console.warn('Canvas already exists.'); + return; + } + + const container = document.querySelector('.santa-container'); + if (!container) { + console.error('Error: No element with class "santa-container" found.'); + return; + } + + canvas = document.createElement('canvas'); + canvas.id = 'snowfallCanvas'; + container.appendChild(canvas); + ctx = canvas.getContext('2d'); + + resizeCanvas(container); + window.addEventListener('resize', () => resizeCanvas(container)); +} + +function removeCanvas() { + const canvas = document.getElementById('snowfallCanvas'); + if (canvas) { + canvas.remove(); + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + console.log('Animation frame canceled'); + } + if (animationFrameIdSanta) { + cancelAnimationFrame(animationFrameIdSanta); + animationFrameIdSanta = null; + console.log('Santa animation frame canceled'); + } + console.log('Canvas removed'); + } +} + +function resizeCanvas(container) { + if (!canvas) return; + const rect = container.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; +} + +function createSnowflakes(container) { + return Array.from({ length: snowflakesCount }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + radius: Math.random() * 0.6 + 1, + speed: Math.random() * snowFallSpeed + 1, + swing: Math.random() * 2 - 1, + })); +} + +// Initialize snowflakes +let snowflakes = []; + +function drawSnowflakes() { + if (!ctx || !canvas) { + console.error('Error: Canvas or context not found.'); + return; + } + ctx.clearRect(0, 0, canvas.width, canvas.height); // empty canvas + + snowflakes.forEach(flake => { + ctx.beginPath(); + ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2); + ctx.fillStyle = 'white'; // color of snowflakes + ctx.fill(); + }); +} + +function updateSnowflakes() { + snowflakes.forEach(flake => { + flake.y += flake.speed; // downwards movement + flake.x += flake.swing; // sideways movement + + // reset snowflake if it reaches the bottom + if (flake.y > canvas.height) { + flake.y = 0; + flake.x = Math.random() * canvas.width; // with new random X position + } + + // wrap snowflakes around the screen edges + if (flake.x > canvas.width) flake.x = 0; + if (flake.x < 0) flake.x = canvas.width; + }); +} + +// credits: flaticon.com +const presentImages = [ + 'Seasonals/Resources/santa_images/gift1.png', + 'Seasonals/Resources/santa_images/gift2.png', + 'Seasonals/Resources/santa_images/gift3.png', + 'Seasonals/Resources/santa_images/gift4.png', + 'Seasonals/Resources/santa_images/gift5.png', + 'Seasonals/Resources/santa_images/gift6.png', + 'Seasonals/Resources/santa_images/gift7.png', + 'Seasonals/Resources/santa_images/gift8.png', +]; + +// credits: https://www.animatedimages.org/img-animated-santa-claus-image-0420-85884.htm +const santaImage = 'Seasonals/Resources/santa_images/santa.gif'; + + +function createSantaElement() { + const santa = document.createElement('img'); + santa.src = santaImage; + santa.classList.add('santa'); + const santaContainer = document.querySelector('.santa-container'); + santaContainer.appendChild(santa); +} + +function dropPresent(santa, fromLeft) { + const presentSrc = presentImages[Math.floor(Math.random() * presentImages.length)]; + const present = document.createElement('img'); + present.src = presentSrc; + present.classList.add('present'); + santa.parentElement.appendChild(present); + + // Get Santa's position + const santaRect = santa.getBoundingClientRect(); + present.style.left = fromLeft ? `${santaRect.left}px` : `${santaRect.left + santaRect.width - 15}px`; + present.style.top = `${santaRect.bottom - 50}px`; + + // Start falling + const duration = Math.random() * (maxPresentFallSpeed - minPresentFallSpeed) + minPresentFallSpeed; + present.style.transition = `top ${duration}s linear`; + requestAnimationFrame(() => { + present.style.top = `${window.innerHeight}px`; + }); + + // Remove from DOM after animation + present.addEventListener('transitionend', () => { + present.remove(); + }); +} + +function reloadSantaGif() { + const santa = document.querySelector('.santa'); + const src = santa.src; + santa.src = ''; + santa.src = src; +} + +function animateSanta() { + const santa = document.querySelector('.santa'); + + function startAnimation() { + const santaHeight = santa.offsetHeight; + if (santaHeight === 0) { + setTimeout(startAnimation, 100); + return; + } + // console.log('Santa height: ', santaHeight); + + const screenWidth = window.innerWidth; + const screenHeight = window.innerHeight; + const fromLeft = Math.random() < 0.5; + const startX = fromLeft ? -220 : screenWidth + 220; + const endX = fromLeft ? screenWidth + 220 : -220; + const startY = Math.random() * (screenHeight / 5) + santaHeight; // Restrict to upper screen + const endY = Math.random() * (screenHeight / 5) + santaHeight; // Restrict to upper screen + const angle = Math.random() * 16 - 8; // -8 to 8 degrees + + santa.style.left = `${startX}px`; + santa.style.top = `${startY}px`; + santa.style.transform = `rotate(${angle}deg) ${fromLeft ? 'scaleX(-1)' : 'scaleX(1)'}`; // Mirror if not from left + + let duration; + if (isMobile) { + duration = santaSpeedMobile * 1000; + } else { + duration = santaSpeed * 1000; + } + const deltaX = endX - startX; + const deltaY = endY - startY; + const startTime = performance.now(); + + function move() { + const currentTime = performance.now(); + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + + const currentY = startY + deltaY * progress - 50 * Math.sin(progress * Math.PI); + santa.style.left = `${startX + deltaX * progress}px`; + santa.style.top = `${currentY}px`; + + if (Math.random() < 0.05) { // 5% chance to drop a present + dropPresent(santa, fromLeft); + } + + if (progress < 1) { + animationFrameIdSanta = requestAnimationFrame(move); + } else { + const pause = Math.random() * ((maxSantaRestTime - minSantaRestTime) * 1000) + minSantaRestTime * 1000; + setTimeout(animateSanta, pause); + } + } + + animationFrameIdSanta = requestAnimationFrame(move); + } + + reloadSantaGif(); + + startAnimation(); +} + + +function animateAll() { + drawSnowflakes(); + updateSnowflakes(); + animationFrameId = requestAnimationFrame(animateAll); +} + +// initialize santa +function initializeSanta() { + if (!santaIsFlying) { + console.warn('Sante is disabled.'); + return; // exit if santa is disabled + } + const container = document.querySelector('.santa-container'); + if (container) { + const screenWidth = window.innerWidth; // get the screen width to detect mobile devices + if (screenWidth < 768) { // lower count of snowflakes on mobile devices + isMobile = true; + console.log('Mobile device detected. Reducing snowflakes count.'); + snowflakesCount = snowflakesCountMobile; + } + + console.log('Santa enabled.'); + initializeCanvas(); + snowflakes = createSnowflakes(container); + createSantaElement(); + animateAll(); + animateSanta(); + } +} + +initializeSanta(); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/santa_images/gift1.png b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift1.png new file mode 100644 index 0000000..3242563 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift1.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/santa_images/gift2.png b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift2.png new file mode 100644 index 0000000..23e0e19 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift2.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/santa_images/gift3.png b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift3.png new file mode 100644 index 0000000..d8876a7 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift3.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/santa_images/gift4.png b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift4.png new file mode 100644 index 0000000..16e7224 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift4.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/santa_images/gift5.png b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift5.png new file mode 100644 index 0000000..d8876a7 Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift5.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/santa_images/gift6.png b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift6.png new file mode 100644 index 0000000..befdd9c Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift6.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/santa_images/gift7.png b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift7.png new file mode 100644 index 0000000..681905c Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift7.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/santa_images/gift8.png b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift8.png new file mode 100644 index 0000000..96b7d7e Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/santa_images/gift8.png differ diff --git a/Jellyfin.Plugin.Seasonals/Web/santa_images/santa.gif b/Jellyfin.Plugin.Seasonals/Web/santa_images/santa.gif new file mode 100644 index 0000000..657ef2f Binary files /dev/null and b/Jellyfin.Plugin.Seasonals/Web/santa_images/santa.gif differ diff --git a/Jellyfin.Plugin.Seasonals/Web/seasonals.js b/Jellyfin.Plugin.Seasonals/Web/seasonals.js new file mode 100644 index 0000000..833358b --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/seasonals.js @@ -0,0 +1,206 @@ +// theme-configs.js + +// theme configurations +const themeConfigs = { + snowflakes: { + css: 'Seasonals/Resources/snowflakes.css', + js: 'Seasonals/Resources/snowflakes.js', + containerClass: 'snowflakes' + }, + snowfall: { + css: 'Seasonals/Resources/snowfall.css', + js: 'Seasonals/Resources/snowfall.js', + containerClass: 'snowfall-container' + }, + snowstorm: { + css: 'Seasonals/Resources/snowstorm.css', + js: 'Seasonals/Resources/snowstorm.js', + containerClass: 'snowstorm-container' + }, + fireworks: { + css: 'Seasonals/Resources/fireworks.css', + js: 'Seasonals/Resources/fireworks.js', + containerClass: 'fireworks' + }, + halloween: { + css: 'Seasonals/Resources/halloween.css', + js: 'Seasonals/Resources/halloween.js', + containerClass: 'halloween-container' + }, + hearts: { + css: 'Seasonals/Resources/hearts.css', + js: 'Seasonals/Resources/hearts.js', + containerClass: 'hearts-container' + }, + christmas: { + css: 'Seasonals/Resources/christmas.css', + js: 'Seasonals/Resources/christmas.js', + containerClass: 'christmas-container' + }, + santa: { + css: 'Seasonals/Resources/santa.css', + js: 'Seasonals/Resources/santa.js', + containerClass: 'santa-container' + }, + autumn: { + css: 'Seasonals/Resources/autumn.css', + js: 'Seasonals/Resources/autumn.js', + containerClass: 'autumn-container' + }, + easter: { + css: 'Seasonals/Resources/easter.css', + js: 'Seasonals/Resources/easter.js', + containerClass: 'easter-container' + }, + summer: { + css: 'Seasonals/Resources/summer.css', + js: 'Seasonals/Resources/summer.js', + containerClass: 'summer-container' + }, + spring: { + css: 'Seasonals/Resources/spring.css', + js: 'Seasonals/Resources/spring.js', + containerClass: 'spring-container' + }, + none: { + containerClass: 'none' + }, +}; + +// determine current theme based on the current month +function determineCurrentTheme() { + const date = new Date(); + const month = date.getMonth(); // 0-11 + const day = date.getDate(); // 1-31 + + if ((month === 11 && day >= 28) || (month === 0 && day <= 5)) return 'fireworks'; //new year fireworks december 28 - january 5 + + if (month === 1 && day >= 10 && day <= 18) return 'hearts'; // valentine's day february 10 - 18 + + if (month === 11 && day >= 22 && day <= 27) return 'santa'; // christmas december 22 - 27 + // if (month === 11 && day >= 22 && day <= 27) return 'christmas'; // christmas december 22 - 27 + + if (month === 11) return 'snowflakes'; // snowflakes december + if (month === 0 || month === 1) return 'snowfall'; // snow january, february + // if (month === 0 || month === 1) return 'snowstorm'; // snow january, february + + if ((month === 2 && day >= 25) || (month === 3 && day <= 25)) return 'easter'; // easter march 25 - april 25 + + //NOT IMPLEMENTED YET + //if (month >= 2 && month <= 4) return 'spring'; // spring march, april, may + + //NOT IMPLEMENTED YET + //if (month >= 5 && month <= 7) return 'summer'; // summer june, july, august + + if ((month === 9 && day >= 24) || (month === 10 && day <= 5)) return 'halloween'; // halloween october 24 - november 5 + + if (month >= 8 && month <= 10) return 'autumn'; // autumn september, october, november + + return 'none'; // Fallback (nothing) +} + +// load theme csss +function loadThemeCSS(cssPath) { + if (!cssPath) return; + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = cssPath; + + link.onerror = () => { + console.error(`Failed to load CSS: ${cssPath}`); + }; + + document.body.appendChild(link); + console.log(`CSS file "${cssPath}" loaded.`); +} + +// load theme js +function loadThemeJS(jsPath) { + if (!jsPath) return; + + const script = document.createElement('script'); + script.src = jsPath; + + script.onerror = () => { + console.error(`Failed to load JS: ${jsPath}`); + }; + + document.body.appendChild(script); + console.log(`JS file "${jsPath}" loaded.`); +} + +// update theme container class name +function updateThemeContainer(containerClass) { + // Create container if it doesn't exist + let container = document.querySelector('.seasonals-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'seasonals-container'; + document.body.appendChild(container); + } + + container.className = `seasonals-container ${containerClass}`; + console.log(`Seasonals-Container class updated to "${containerClass}".`); +} + +function removeSelf() { + const script = document.currentScript; + if (script) script.parentNode.removeChild(script); + console.log('External script removed:', script); +} + +// initialize theme +async function initializeTheme() { + let automateThemeSelection = true; + let defaultTheme = 'none'; + + try { + const response = await fetch('/Seasonals/Config'); + if (response.ok) { + const config = await response.json(); + automateThemeSelection = config.automateSeasonSelection; + defaultTheme = config.selectedSeason; + } else { + console.error('Failed to fetch Seasonals config'); + } + } catch (error) { + console.error('Error fetching Seasonals config:', error); + } + + let currentTheme; + if (!automateThemeSelection) { + currentTheme = defaultTheme; + } else { + currentTheme = determineCurrentTheme(); + } + + console.log(`Selected theme: ${currentTheme}`); + + if (currentTheme === 'none') { + console.log('No theme selected.'); + removeSelf(); + return; + } + + const theme = themeConfigs[currentTheme]; + + if (!theme) { + console.error(`Theme "${currentTheme}" not found.`); + return; + } + updateThemeContainer(theme.containerClass); + + if (theme.css) loadThemeCSS(theme.css); + if (theme.js) loadThemeJS(theme.js); + + console.log(`Theme "${currentTheme}" loaded.`); + + removeSelf(); +} + + +//document.addEventListener('DOMContentLoaded', initializeTheme); +document.addEventListener('DOMContentLoaded', () => { + initializeTheme(); +}); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/snowfall.css b/Jellyfin.Plugin.Seasonals/Web/snowfall.css new file mode 100644 index 0000000..9bf707b --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/snowfall.css @@ -0,0 +1,17 @@ +.snowfall-container { + position: fixed; + width: 100%; + height: 100vh; + background: transparent; + overflow: hidden; + pointer-events: none; +} + +#snowfallCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/snowfall.js b/Jellyfin.Plugin.Seasonals/Web/snowfall.js new file mode 100644 index 0000000..bfc95c7 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/snowfall.js @@ -0,0 +1,170 @@ +const snowfall = true; // enable/disable snowfall +let snowflakesCount = 500; // count of snowflakes (recommended values: 300-600) +const snowflakesCountMobile = 250; // count of snowflakes on mobile devices +const snowFallSpeed = 3; // speed of snowfall (recommended values: 0-5) + +let msgPrinted = false; // flag to prevent multiple console messages + +let canvas, ctx; // canvas and context for drawing snowflakes +let animationFrameId; // ID of the animation frame + +// function to check and control the snowfall +function toggleSnowfall() { + const snowfallContainer = document.querySelector('.snowfall-container'); + if (!snowfallContainer) 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 snowfall if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + snowfallContainer.style.display = 'none'; // hide snowfall + removeCanvas(); + if (!msgPrinted) { + console.log('Snowfall hidden'); + msgPrinted = true; + } + } else { + snowfallContainer.style.display = 'block'; // show snowfall + if (!animationFrameId) { + initializeCanvas(); + snowflakes = createSnowflakes(snowfallContainer); + animateSnowfall(); + } else { + console.warn('could not initialize snowfall: animation frame is already running'); + } + + if (msgPrinted) { + console.log('Snowfall visible'); + msgPrinted = false; + } + } +} + +// observe changes in the DOM +const observer = new MutationObserver(toggleSnowfall); + +// 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) +}); + + +function initializeCanvas() { + if (document.getElementById('snowfallCanvas')) { + console.warn('Canvas already exists.'); + return; + } + + const container = document.querySelector('.snowfall-container'); + if (!container) { + console.error('Error: No element with class "snowfall-container" found.'); + return; + } + + canvas = document.createElement('canvas'); + canvas.id = 'snowfallCanvas'; + container.appendChild(canvas); + ctx = canvas.getContext('2d'); + + resizeCanvas(container); + window.addEventListener('resize', () => resizeCanvas(container)); +} + +function removeCanvas() { + const canvas = document.getElementById('snowfallCanvas'); + if (canvas) { + canvas.remove(); + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + console.log('Animation frame canceled'); + } + console.log('Canvas removed'); + } +} + +function resizeCanvas(container) { + if (!canvas) return; + const rect = container.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; +} + +function createSnowflakes(container) { + return Array.from({ length: snowflakesCount }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + radius: Math.random() * 1.2 + 1, + speed: Math.random() * snowFallSpeed + 1, + swing: Math.random() * 2 - 1, + })); +} + +// Initialize snowflakes +let snowflakes = []; + +function drawSnowflakes() { + if (!ctx || !canvas) { + console.error('Error: Canvas or context not found.'); + return; + } + ctx.clearRect(0, 0, canvas.width, canvas.height); // empty canvas + + snowflakes.forEach(flake => { + ctx.beginPath(); + ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2); + ctx.fillStyle = 'white'; // color of snowflakes + ctx.fill(); + }); +} + +function updateSnowflakes() { + snowflakes.forEach(flake => { + flake.y += flake.speed; // downwards movement + flake.x += flake.swing; // sideways movement + + // reset snowflake if it reaches the bottom + if (flake.y > canvas.height) { + flake.y = 0; + flake.x = Math.random() * canvas.width; // with new random X position + } + + // wrap snowflakes around the screen edges + if (flake.x > canvas.width) flake.x = 0; + if (flake.x < 0) flake.x = canvas.width; + }); +} + +function animateSnowfall() { + drawSnowflakes(); + updateSnowflakes(); + animationFrameId = requestAnimationFrame(animateSnowfall); +} + +// initialize snowfall +function initializeSnowfall() { + if (!snowfall) { + console.warn('Snowfall is disabled.'); + return; // exit if snowfall is disabled + } + const container = document.querySelector('.snowfall-container'); + if (container) { + const screenWidth = window.innerWidth; // get the screen width to detect mobile devices + if (screenWidth < 768) { // lower count of snowflakes on mobile devices + console.log('Mobile device detected. Reducing snowflakes count.'); + snowflakesCount = snowflakesCountMobile; + } + + console.log('Snowfall enabled.'); + initializeCanvas(); + snowflakes = createSnowflakes(container); + animateSnowfall(); + } +} + +initializeSnowfall(); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/snowflakes.css b/Jellyfin.Plugin.Seasonals/Web/snowflakes.css new file mode 100644 index 0000000..e898e7d --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/snowflakes.css @@ -0,0 +1,132 @@ +.snowflakes { + display: block; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.snowflake { + 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: heart-fall, heart-shake; + -webkit-animation-duration: 12s, 3s; + -webkit-animation-timing-function: linear, ease-in-out; + -webkit-animation-iteration-count: infinite, infinite; + animation-name: snowflakes-fall, snowflakes-shake; + animation-duration: 12s, 3s; + animation-timing-function: linear, ease-in-out; + animation-iteration-count: infinite, infinite; +} + +@-webkit-keyframes snowflakes-fall { + 0% { + top: -10%; + } + + 100% { + top: 100%; + } +} + +@-webkit-keyframes snowflakes-shake { + + 0%, + 100% { + -webkit-transform: translateX(0); + transform: translateX(0); + } + + 50% { + -webkit-transform: translateX(80px); + transform: translateX(80px); + } +} + +@keyframes snowflakes-fall { + 0% { + top: -10%; + } + + 100% { + top: 100%; + } +} + +@keyframes snowflakes-shake { + + 0%, + 100% { + transform: translateX(0); + } + + 50% { + transform: translateX(80px); + } +} + +.snowflake:nth-of-type(0) { + left: 0%; + animation-delay: 0s, 0s; +} + +.snowflake:nth-of-type(1) { + left: 10%; + animation-delay: 1s, 1s; +} + +.snowflake:nth-of-type(2) { + left: 20%; + animation-delay: 6s, 0.5s; +} + +.snowflake:nth-of-type(3) { + left: 30%; + animation-delay: 4s, 2s; +} + +.snowflake:nth-of-type(4) { + left: 40%; + animation-delay: 2s, 2s; +} + +.snowflake:nth-of-type(5) { + left: 50%; + animation-delay: 8s, 3s; +} + +.snowflake:nth-of-type(6) { + left: 60%; + animation-delay: 6s, 2s; +} + +.snowflake:nth-of-type(7) { + left: 70%; + animation-delay: 2.5s, 1s; +} + +.snowflake:nth-of-type(8) { + left: 80%; + animation-delay: 1s, 0s; +} + +.snowflake:nth-of-type(9) { + left: 90%; + animation-delay: 3s, 1.5s; +} + +.snowflake:nth-of-type(10) { + left: 25%; + animation-delay: 2s, 0s; +} + +.snowflake:nth-of-type(11) { + left: 65%; + animation-delay: 4s, 2.5s; +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/snowflakes.js b/Jellyfin.Plugin.Seasonals/Web/snowflakes.js new file mode 100644 index 0000000..1ba0519 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/snowflakes.js @@ -0,0 +1,132 @@ +const snowflakes = true; // enable/disable snowflakes +const randomSnowflakes = true; // enable random Snowflakes +const randomSnowflakesMobile = false; // enable random Snowflakes on mobile devices +const enableColoredSnowflakes = true; // enable colored snowflakes on mobile devices +const enableDiffrentDuration = true; // enable different animation duration for random symbols +const snowflakeCount = 25; // count of random extra snowflakes + + +let msgPrinted = false; // flag to prevent multiple console messages + +// function to check and control the snowflakes +function toggleSnowflakes() { + const snowflakeContainer = document.querySelector('.snowflakes'); + if (!snowflakeContainer) 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 snowflakes if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + snowflakeContainer.style.display = 'none'; // hide snowflakes + if (!msgPrinted) { + console.log('Snowflakes hidden'); + msgPrinted = true; + } + } else { + snowflakeContainer.style.display = 'block'; // show snowflakes + if (msgPrinted) { + console.log('Snowflakes visible'); + msgPrinted = false; + } + } +} + +// observe changes in the DOM +const observer = new MutationObserver(toggleSnowflakes); + +// 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) +}); + +function addRandomSnowflakes(count) { + const snowflakeContainer = document.querySelector('.snowflakes'); // get the snowflake container + if (!snowflakeContainer) return; // exit if snowflake container is not found + + console.log('Adding random snowflakes'); + + const snowflakeSymbols = ['❅', '❆']; // some snowflake symbols + const snowflakeSymbolsMobile = ['❅', '❆', '❄']; // some snowflake symbols mobile version + + for (let i = 0; i < count; i++) { + // create a new snowflake element + const snowflake = document.createElement('div'); + snowflake.classList.add('snowflake'); + + // pick a random snowflake symbol + if (enableColoredSnowflakes) { + snowflake.textContent = snowflakeSymbolsMobile[Math.floor(Math.random() * snowflakeSymbolsMobile.length)]; + } else { + snowflake.textContent = snowflakeSymbols[Math.floor(Math.random() * snowflakeSymbols.length)]; + } + + // 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() * 8; // delay (0s to 8s) + const randomAnimationDelay2 = Math.random() * 5; // delay (0s to 5s) + + // apply styles + snowflake.style.left = `${randomLeft}%`; + snowflake.style.animationDelay = `${randomAnimationDelay}s, ${randomAnimationDelay2}s`; + + // set random animation duration + if (enableDiffrentDuration) { + const randomAnimationDuration = Math.random() * 14 + 10; // delay (10s to 14s) + const randomAnimationDuration2 = Math.random() * 5 + 3; // delay (3s to 5s) + snowflake.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + // add the snowflake to the container + snowflakeContainer.appendChild(snowflake); + } + console.log('Random snowflakes added'); +} + +// initialize standard snowflakes +function initSnowflakes() { + const snowflakesContainer = document.querySelector('.snowflakes') || document.createElement("div"); + + if (!document.querySelector('.snowflakes')) { + snowflakesContainer.className = "snowflakes"; + snowflakesContainer.setAttribute("aria-hidden", "true"); + document.body.appendChild(snowflakesContainer); + } + + // Array of snowflake characters + const snowflakeSymbols = ['❅', '❆']; + + // create the 12 standard snowflakes + for (let i = 0; i < 12; i++) { + const snowflake = document.createElement('div'); + snowflake.className = 'snowflake'; + snowflake.textContent = snowflakeSymbols[i % 2]; // change between ❅ and ❆ + + // set random animation duration + if (enableDiffrentDuration) { + const randomAnimationDuration = Math.random() * 14 + 10; // delay (10s to 14s) + const randomAnimationDuration2 = Math.random() * 5 + 3; // delay (3s to 5s) + snowflake.style.animationDuration = `${randomAnimationDuration}s, ${randomAnimationDuration2}s`; + } + + snowflakesContainer.appendChild(snowflake); + } +} + +// initialize snowflakes and add random snowflakes +function initializeSnowflakes() { + if (!snowflakes) return; // exit if snowflakes are disabled + initSnowflakes(); + toggleSnowflakes(); + + const screenWidth = window.innerWidth; // get the screen width to detect mobile devices + if (randomSnowflakes && (screenWidth > 768 || randomSnowflakesMobile)) { // add random snowflakes only on larger screens, unless enabled for mobile devices + addRandomSnowflakes(snowflakeCount); + } +} + +initializeSnowflakes(); \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/snowstorm.css b/Jellyfin.Plugin.Seasonals/Web/snowstorm.css new file mode 100644 index 0000000..56b98f7 --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/snowstorm.css @@ -0,0 +1,7 @@ +.snowflake { + position: fixed; + background-color: rgba(255, 255, 255, 0.8); + border-radius: 50%; + pointer-events: none; + opacity: 0.7; +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Seasonals/Web/snowstorm.js b/Jellyfin.Plugin.Seasonals/Web/snowstorm.js new file mode 100644 index 0000000..eddd6fc --- /dev/null +++ b/Jellyfin.Plugin.Seasonals/Web/snowstorm.js @@ -0,0 +1,173 @@ +const snowstorm = true; // enable/disable snowstorm +let snowflakesCount = 500; // count of snowflakes (recommended values: 300-600) +const snowflakesCountMobile = 250; // count of snowflakes on mobile devices +const snowFallSpeed = 6; // speed of snowfall (recommended values: 4-8) +const horizontalWind = 4; // horizontal wind speed (recommended value: 4) +const verticalVariation = 2; // vertical variation (recommended value: 2) + +let msgPrinted = false; // flag to prevent multiple console messages + +let canvas, ctx; // canvas and context for drawing snowflakes +let animationFrameId; // ID of the animation frame + +// function to check and control the snowstorm +function toggleSnowstorm() { + const snowstormContainer = document.querySelector('.snowstorm-container'); + if (!snowstormContainer) 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 snowstorm if video/trailer player is active or dashboard is visible + if (videoPlayer || trailerPlayer || isDashboard || hasUserMenu) { + snowstormContainer.style.display = 'none'; // hide snowstorm + removeCanvas(); + if (!msgPrinted) { + console.log('Snowstorm hidden'); + msgPrinted = true; + } + } else { + snowstormContainer.style.display = 'block'; // show snowstorm + if (!animationFrameId) { + initializeCanvas(); + snowflakes = createSnowflakes(snowstormContainer); + animateSnowstorm(); + } else { + console.warn('could not initialize snowfall: animation frame is already running'); + } + + if (msgPrinted) { + console.log('Snowstorm visible'); + msgPrinted = false; + } + } +} + +// observe changes in the DOM +const observer = new MutationObserver(toggleSnowstorm); + +// 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) +}); + + +function initializeCanvas() { + if (document.getElementById('snowfallCanvas')) { + console.warn('Canvas already exists.'); + return; + } + + const container = document.querySelector('.snowstorm-container'); + if (!container) { + console.error('Error: No element with class "snowfall-container" found.'); + return; + } + + canvas = document.createElement('canvas'); + canvas.id = 'snowstormCanvas'; + container.appendChild(canvas); + ctx = canvas.getContext('2d'); + + resizeCanvas(container); + window.addEventListener('resize', () => resizeCanvas(container)); +} + +function removeCanvas() { + const canvas = document.getElementById('snowstormCanvas'); + if (canvas) { + canvas.remove(); + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + console.log('Animation frame canceled'); + } + console.log('Canvas removed'); + } +} + +function resizeCanvas(container) { + if (!canvas) return; + const rect = container.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; +} + +function createSnowflakes(container) { + return Array.from({ length: snowflakesCount }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + radius: Math.random() * 1.2 + 1, + fallspeed: Math.random() * snowFallSpeed + 2, + horizontal: Math.random() * horizontalWind * 2 - horizontalWind, + vertical: Math.random() * verticalVariation * 2 - verticalVariation, + })); +} + +// Initialize snowflakes +let snowflakes = []; + +function drawSnowflakes() { + if (!ctx || !canvas) { + console.error('Error: Canvas or context not found.'); + return; + } + ctx.clearRect(0, 0, canvas.width, canvas.height); // empty canvas + + snowflakes.forEach(flake => { + ctx.beginPath(); + ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2); + ctx.fillStyle = 'white'; // color of snowflakes + ctx.fill(); + }); +} + +function updateSnowflakes() { + snowflakes.forEach(flake => { + flake.y += flake.fallspeed + flake.vertical; // downwards movement + flake.x += flake.horizontal; // sideways movement + + // reset snowflake if it reaches the bottom + if (flake.y > canvas.height) { + flake.y = 0; + flake.x = Math.random() * canvas.width; // with new random X position + } + + // wrap snowflakes around the screen edges + if (flake.x > canvas.width) flake.x = 0; + if (flake.x < 0) flake.x = canvas.width; + }); +} + +function animateSnowstorm() { + drawSnowflakes(); + updateSnowflakes(); + animationFrameId = requestAnimationFrame(animateSnowstorm); +} + +// initialize snowfall +function initializeSnowstorm() { + if (!snowstorm) { + console.warn('Snowstorm is disabled.'); + return; // exit if snowfall is disabled + } + const container = document.querySelector('.snowstorm-container'); + if (container) { + const screenWidth = window.innerWidth; // get the screen width to detect mobile devices + if (screenWidth < 768) { // lower count of snowflakes on mobile devices + console.log('Mobile device detected. Reducing snowflakes count.'); + snowflakesCount = snowflakesCountMobile; + } + + console.log('Snowstorm enabled.'); + initializeCanvas(); + snowflakes = createSnowflakes(container); + animateSnowstorm(); + } +} + +initializeSnowstorm(); diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69633b --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Jellyfin Seasonals Plugin + +This plugin adds seasonal effects (snow, leaves, etc.) to the Jellyfin web interface. + +## Installation + +1. Build the plugin and install it in Jellyfin. +2. Restart Jellyfin. +3. The plugin will automatically attempt to inject the necessary script into your `index.html`. + +## Configuration + +Go to the Jellyfin Dashboard > Plugins > Seasonals to configure the active season or enable automatic selection. diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..32bc3f1 --- /dev/null +++ b/build.yaml @@ -0,0 +1,15 @@ +--- +name: "Seasonals" +guid: "ef1e863f-cbb0-4e47-9f23-f0cbb1826ad4" +version: "1.0.0.0" +targetAbi: "10.10.7.0" +framework: "net9.0" +overview: "Seasonal effects for Jellyfin" +description: > + Adds seasonal effects like snow, leaves, etc. to the Jellyfin web interface. +category: "General" +owner: "CodeDevMLH" +artifacts: +- "Jellyfin.Plugin.Seasonals.dll" +changelog: > + Initial release diff --git a/jellyfin.ruleset b/jellyfin.ruleset new file mode 100644 index 0000000..8af791c --- /dev/null +++ b/jellyfin.ruleset @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c23ade5 --- /dev/null +++ b/manifest.json @@ -0,0 +1,21 @@ +[ + { + "guid": "ef1e863f-cbb0-4e47-9f23-f0cbb1826ad4", + "name": "Seasonals", + "description": "Adds seasonal effects like snow, leaves, etc. to the Jellyfin web interface.", + "overview": "Seasonal effects for Jellyfin", + "owner": "CodeDevMLH", + "category": "General", + "imageUrl": "https://raw.githubusercontent.com/CodeDevMLH/jellyfin-plugin-seasonals/main/icon.png", + "versions": [ + { + "version": "1.0.0.0", + "changelog": "Initial release", + "targetAbi": "10.10.7.0", + "sourceUrl": "https://github.com/CodeDevMLH/jellyfin-plugin-seasonals", + "checksum": "46e786c6e26b69748775a77474025e18", + "timestamp": "2025-12-14T18:18:50Z" + } + ] + } +] \ No newline at end of file