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/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..0827b9c --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,45 @@ +name: '🏗️ Build Plugin' + +on: + push: + branches: + - dev + paths-ignore: + - '**/*.md' + - '.gitea/**' + - '.github/**' + - 'jellyfin.ruleset' + - '.gitignore' + - '.editorconfig' + - 'LICENSE' + - 'logo.png' + pull_request: + paths-ignore: + - '**/*.md' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "9.x" + + - name: Build Jellyfin Plugin + run: | + dotnet build Jellyfin.Plugin.MediaBarEnhanced/Jellyfin.Plugin.MediaBarEnhanced.csproj --configuration Release --output bin/Publish + cd bin/Publish + zip -r Jellyfin.Plugin.MediaBarEnhanced.zip * + + - name: Upload Artifact + uses: christopherHX/gitea-upload-artifact@v4 + with: + name: plugin-build-artifact + retention-days: 5 + if-no-files-found: error + path: bin/Publish/Jellyfin.Plugin.MediaBarEnhanced.zip diff --git a/.gitea/workflows/release_automation.yml b/.gitea/workflows/release_automation.yml new file mode 100644 index 0000000..0c52459 --- /dev/null +++ b/.gitea/workflows/release_automation.yml @@ -0,0 +1,103 @@ +name: Auto Release Plugin + +on: + push: + branches: + - main + paths-ignore: + - '.gitea/**' + - '.github/**' + - '*.md' + - 'jellyfin.ruleset' + - '.gitignore' + - '.editorconfig' + - 'LICENSE' + - 'logo.png' + +jobs: + build-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '9.x' + + - name: Read Version from Manifest + id: read_version + run: | + VERSION=$(jq -r '.[0].versions[0].version' manifest.json) + CHANGELOG=$(jq -r '.[0].versions[0].changelog' manifest.json) + TARGET_ABI=$(jq -r '.[0].versions[0].targetAbi' manifest.json) + + echo "Detected Version: $VERSION" + echo "VERSION=$VERSION" >> $GITHUB_ENV + + # Escape newlines in changelog for GITHUB_ENV + echo "CHANGELOG<> $GITHUB_ENV + echo "$CHANGELOG" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Build and Zip + shell: bash + run: | + # Inject version from manifest into the build + dotnet build Jellyfin.Plugin.MediaBarEnhanced/Jellyfin.Plugin.MediaBarEnhanced.csproj --configuration Release --output bin/Publish /p:Version=${{ env.VERSION }} /p:AssemblyVersion=${{ env.VERSION }} + + cd bin/Publish + zip -r Jellyfin.Plugin.MediaBarEnhanced.zip * + cd ../.. + + # Calculate hash + HASH=$(md5sum bin/Publish/Jellyfin.Plugin.MediaBarEnhanced.zip | awk '{ print $1 }') + TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Export variables for next steps + echo "ZIP_HASH=$HASH" >> $GITHUB_ENV + echo "BUILD_TIME=$TIME" >> $GITHUB_ENV + echo "ZIP_PATH=bin/Publish/Jellyfin.Plugin.MediaBarEnhanced.zip" >> $GITHUB_ENV + + - name: Update manifest.json + shell: bash + run: | + REPO_OWNER="${{ github.repository_owner }}" + REPO_NAME="${{ github.event.repository.name }}" + VERSION="${{ env.VERSION }}" + DOWNLOAD_URL="https://git.mahom03-spacecloud.de/${{ github.repository }}/releases/download/v$VERSION/Jellyfin.Plugin.MediaBarEnhanced.zip" + + echo "Updating manifest.json with:" + echo "Hash: ${{ env.ZIP_HASH }}" + echo "Time: ${{ env.BUILD_TIME }}" + echo "Url: $DOWNLOAD_URL" + + jq --arg hash "${{ env.ZIP_HASH }}" \ + --arg time "${{ env.BUILD_TIME }}" \ + --arg url "$DOWNLOAD_URL" \ + '.[0].versions[0].checksum = $hash | .[0].versions[0].timestamp = $time | .[0].versions[0].sourceUrl = $url' \ + manifest.json > manifest.json.tmp && mv manifest.json.tmp manifest.json + + - name: Commit manifest.json + uses: stefanzweifel/git-auto-commit-action@v7 + with: + commit_message: "Update manifest.json for release v${{ env.VERSION }} [skip ci]" + file_pattern: manifest.json + + - name: Create Release + uses: akkuman/gitea-release-action@v1 + with: + server_url: "https://git.mahom03-spacecloud.de" + body: ${{ env.CHANGELOG }} + token: ${{ secrets.GITHUB_TOKEN }} + files: ${{ env.ZIP_PATH }} + name: "v${{ env.VERSION }}" + tag_name: "v${{ env.VERSION }}" + draft: false + prerelease: false diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..2f561e6 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>jellyfin/.github//renovate-presets/default" + ] +} \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..9da51e5 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,45 @@ +name: '🏗️ Build Plugin' + +on: + push: + branches: + - dev + paths-ignore: + - '**/*.md' + - '.gitea/**' + - '.github/**' + - 'jellyfin.ruleset' + - '.gitignore' + - '.editorconfig' + - 'LICENSE' + - 'logo.png' + pull_request: + paths-ignore: + - '**/*.md' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "9.x" + + - name: Build Jellyfin Plugin + run: | + dotnet build Jellyfin.Plugin.MediaBarEnhanced/Jellyfin.Plugin.MediaBarEnhanced.csproj --configuration Release --output bin/Publish + cd bin/Publish + zip -r Jellyfin.Plugin.MediaBarEnhanced.zip * + + - name: Upload Artifact + uses: actions/upload-artifact@v6 + with: + name: plugin-build-artifact + retention-days: 5 + if-no-files-found: error + path: bin/Publish/Jellyfin.Plugin.MediaBarEnhanced.zip \ No newline at end of file diff --git a/.github/workflows/release_automation.yml b/.github/workflows/release_automation.yml new file mode 100644 index 0000000..9cb1927 --- /dev/null +++ b/.github/workflows/release_automation.yml @@ -0,0 +1,101 @@ +name: Auto Release Plugin + +on: + push: + branches: + - main + paths-ignore: + - '.github/**' + - 'README.md' + - 'jellyfin.ruleset' + - '.gitignore' + - '.editorconfig' + - 'LICENSE' + - 'logo.png' + +jobs: + build-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '9.x' + + - name: Read Version from Manifest + id: read_version + run: | + VERSION=$(jq -r '.[0].versions[0].version' manifest.json) + CHANGELOG=$(jq -r '.[0].versions[0].changelog' manifest.json) + TARGET_ABI=$(jq -r '.[0].versions[0].targetAbi' manifest.json) + + echo "Detected Version: $VERSION" + echo "VERSION=$VERSION" >> $GITHUB_ENV + + # Escape newlines in changelog for GITHUB_ENV + echo "CHANGELOG<> $GITHUB_ENV + echo "$CHANGELOG" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Build and Zip + shell: bash + run: | + # Inject version from manifest into the build + dotnet build Jellyfin.Plugin.MediaBarEnhanced/Jellyfin.Plugin.MediaBarEnhanced.csproj --configuration Release --output bin/Publish /p:Version=${{ env.VERSION }} /p:AssemblyVersion=${{ env.VERSION }} + + cd bin/Publish + zip -r Jellyfin.Plugin.MediaBarEnhanced.zip * + cd ../.. + + # Calculate hash + HASH=$(md5sum bin/Publish/Jellyfin.Plugin.MediaBarEnhanced.zip | awk '{ print $1 }') + TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Export variables for next steps + echo "ZIP_HASH=$HASH" >> $GITHUB_ENV + echo "BUILD_TIME=$TIME" >> $GITHUB_ENV + echo "ZIP_PATH=bin/Publish/Jellyfin.Plugin.MediaBarEnhanced.zip" >> $GITHUB_ENV + + - name: Update manifest.json + shell: bash + run: | + REPO_OWNER="${{ github.repository_owner }}" + REPO_NAME="${{ github.event.repository.name }}" + VERSION="${{ env.VERSION }}" + DOWNLOAD_URL="https://github.com/$REPO_OWNER/$REPO_NAME/releases/download/v$VERSION/Jellyfin.Plugin.MediaBarEnhanced.zip" + + echo "Updating manifest.json with:" + echo "Hash: ${{ env.ZIP_HASH }}" + echo "Time: ${{ env.BUILD_TIME }}" + echo "Url: $DOWNLOAD_URL" + + jq --arg hash "${{ env.ZIP_HASH }}" \ + --arg time "${{ env.BUILD_TIME }}" \ + --arg url "$DOWNLOAD_URL" \ + '.[0].versions[0].checksum = $hash | .[0].versions[0].timestamp = $time | .[0].versions[0].sourceUrl = $url' \ + manifest.json > manifest.json.tmp && mv manifest.json.tmp manifest.json + + - name: Commit manifest.json + uses: stefanzweifel/git-auto-commit-action@v7 + with: + commit_message: "Update manifest.json for release v${{ env.VERSION }} [skip ci]" + file_pattern: manifest.json + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: "v${{ env.VERSION }}" + name: "v${{ env.VERSION }}" + # body: ${{ env.CHANGELOG }} + files: ${{ env.ZIP_PATH }} + draft: false + prerelease: false + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..008cfcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +.vs/ +.idea/ +artifacts + +example-plugins/ + +*.md +!README.md \ No newline at end of file diff --git a/Jellyfin.Plugin.MediaBarEnhanced.sln b/Jellyfin.Plugin.MediaBarEnhanced.sln new file mode 100644 index 0000000..d83af93 --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced.sln @@ -0,0 +1,28 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.MediaBarEnhanced", "Jellyfin.Plugin.MediaBarEnhanced\Jellyfin.Plugin.MediaBarEnhanced.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.MediaBarEnhanced/Api/MediaBarEnhancedController.cs b/Jellyfin.Plugin.MediaBarEnhanced/Api/MediaBarEnhancedController.cs new file mode 100644 index 0000000..b807725 --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/Api/MediaBarEnhancedController.cs @@ -0,0 +1,76 @@ +using System; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Jellyfin.Plugin.MediaBarEnhanced; +using Jellyfin.Plugin.MediaBarEnhanced.Configuration; + +namespace Jellyfin.Plugin.MediaBarEnhanced.Api +{ + /// + /// Controller for serving MediaBarEnhanced resources and configuration. + /// + [ApiController] + [Route("MediaBarEnhanced")] + public class MediaBarEnhancedController : ControllerBase + { + /// + /// Gets the current plugin configuration. + /// + /// The configuration object. + [HttpGet("Config")] + [Produces("application/json")] + public ActionResult GetConfig() + { + return MediaBarEnhancedPlugin.Instance?.Configuration ?? new PluginConfiguration(); + } + + /// + /// Serves embedded resources. + /// + /// The path to the resource. + /// The resource file. + [HttpGet("Resources/{*path}")] + public ActionResult GetResource(string path) + { + // Sanitize path + if (string.IsNullOrWhiteSpace(path) || path.Contains("..", StringComparison.Ordinal)) + { + return BadRequest(); + } + + var assembly = typeof(MediaBarEnhancedPlugin).Assembly; + var resourcePath = path.Replace('/', '.').Replace('\\', '.'); + var resourceName = $"Jellyfin.Plugin.MediaBarEnhanced.Web.{resourcePath}"; + + var stream = assembly.GetManifestResourceStream(resourceName); + + // if (stream == null) + // { + // // Try fallback/debug matching + // var allNames = assembly.GetManifestResourceNames(); + // var match = Array.Find(allNames, n => n.EndsWith(resourcePath, StringComparison.OrdinalIgnoreCase)); + + // if (match != null) + // { + // stream = assembly.GetManifestResourceStream(match); + // } + // } + + if (stream == null) + { + return NotFound($"Resource not found: {resourceName}"); + } + + var 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(".html", StringComparison.OrdinalIgnoreCase)) return "text/html"; + return "application/octet-stream"; + } + } +} diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..42209c4 --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/PluginConfiguration.cs @@ -0,0 +1,37 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.MediaBarEnhanced.Configuration +{ + /// + /// Plugin configuration. + /// + public class PluginConfiguration : BasePluginConfiguration + { + public int ShuffleInterval { get; set; } = 7000; + public int RetryInterval { get; set; } = 500; + public int MinSwipeDistance { get; set; } = 50; + public int LoadingCheckInterval { get; set; } = 100; + public int MaxPlotLength { get; set; } = 360; + public int MaxMovies { get; set; } = 15; + public int MaxTvShows { get; set; } = 15; + public int MaxItems { get; set; } = 500; + public int PreloadCount { get; set; } = 3; + public int FadeTransitionDuration { get; set; } = 500; + public int MaxPaginationDots { get; set; } = 15; + public bool SlideAnimationEnabled { get; set; } = true; + public bool EnableVideoBackdrop { get; set; } = true; + public bool UseSponsorBlock { get; set; } = true; + public bool WaitForTrailerToEnd { get; set; } = true; + public bool StartMuted { get; set; } = true; + public bool FullWidthVideo { get; set; } = true; + public bool EnableMobileVideo { get; set; } = false; + public bool ShowTrailerButton { get; set; } = true; + public bool EnableLoadingScreen { get; set; } = true; + public bool EnableKeyboardControls { get; set; } = true; + public bool AlwaysShowArrows { get; set; } = false; + public string CustomMediaIds { get; set; } = ""; + public bool EnableCustomMediaIds { get; set; } = false; + public bool EnableSeasonalContent { get; set; } = false; + public bool IsEnabled { get; set; } = true; + } +} diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html new file mode 100644 index 0000000..f344cee --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html @@ -0,0 +1,431 @@ + + + + + + Media Bar Enhanced Configuration + + + +
+
+
+
+

Media Bar Enhanced

+ + + Help + +
+
+ +
+ + + +
+ +
+ + +
+

Main Plugin Settings

+
+ +
Enable or disable the entire plugin functionality.
+
+
+ +
Show video trailers as background if available.
Adds a + mute/unmute and pause/play button to control the video in the right top corner.
+
+
+ +
Delay slide transition until trailer finishes.
+
+
+ +
Allow video playback on mobile devices.
+
+
+ +
Display a button to open trailer in modal. Only visible if + trailer is not set as backdrop.
+
+
+ + + + + + + +
+ info +
+ All changes require a page refresh (ctrl + r or F5) after saving for changes to take effect. +
+ If old settings persist, please force clear browser cache. +
+
+ +
+ + +
+
+
+
+ + + + +
+ + + \ No newline at end of file diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Helpers/TransformationPatches.cs b/Jellyfin.Plugin.MediaBarEnhanced/Helpers/TransformationPatches.cs new file mode 100644 index 0000000..03ffed8 --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/Helpers/TransformationPatches.cs @@ -0,0 +1,58 @@ +using System; +using Jellyfin.Plugin.MediaBarEnhanced.Model; + +namespace Jellyfin.Plugin.MediaBarEnhanced.Helpers +{ + public static class TransformationPatches + { + public static string IndexHtml(PatchRequestPayload payload) + { + // Always return original content if something fails or is null + string? originalContents = payload?.Contents; + + if (string.IsNullOrEmpty(originalContents)) + { + return originalContents ?? string.Empty; + } + + try + { + // Safety Check: If plugin is disabled, do nothing + if (!MediaBarEnhancedPlugin.Instance.Configuration.IsEnabled) + { + return originalContents; + } + + // Use StringBuilder for efficient modification (conceptually similar to stream processing) + var builder = new System.Text.StringBuilder(originalContents); + + // Inject Script if missing + if (!originalContents.Contains(ScriptInjector.ScriptTag)) + { + var scriptIndex = originalContents.LastIndexOf(ScriptInjector.ScriptMarker, StringComparison.OrdinalIgnoreCase); + if (scriptIndex != -1) + { + builder.Insert(scriptIndex, ScriptInjector.ScriptTag + Environment.NewLine); + } + } + + // Inject CSS if missing + if (!originalContents.Contains(ScriptInjector.CssTag)) + { + var cssIndex = originalContents.LastIndexOf(ScriptInjector.CssMarker, StringComparison.OrdinalIgnoreCase); + if (cssIndex != -1) + { + builder.Insert(cssIndex, ScriptInjector.CssTag + Environment.NewLine); + } + } + + return builder.ToString(); + } + catch + { + // On error, return original content to avoid breaking the UI + return originalContents; + } + } + } +} diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Jellyfin.Plugin.MediaBarEnhanced.csproj b/Jellyfin.Plugin.MediaBarEnhanced/Jellyfin.Plugin.MediaBarEnhanced.csproj new file mode 100644 index 0000000..5db727a --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/Jellyfin.Plugin.MediaBarEnhanced.csproj @@ -0,0 +1,35 @@ + + + + 10.11.0 + net9.0 + net8.0 + Jellyfin.Plugin.MediaBarEnhanced + enable + + + + + Jellyfin Media Bar Enhanced Plugin + CodeDevMLH + 1.5.0.0 + https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin + + + + + + + + + + + + + + + + + + + diff --git a/Jellyfin.Plugin.MediaBarEnhanced/MediaBarEnhancedPlugin.cs b/Jellyfin.Plugin.MediaBarEnhanced/MediaBarEnhancedPlugin.cs new file mode 100644 index 0000000..9b2d1c9 --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/MediaBarEnhancedPlugin.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Jellyfin.Plugin.MediaBarEnhanced.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MediaBarEnhanced +{ + /// + /// The main plugin. + /// + public class MediaBarEnhancedPlugin : BasePlugin, IHasWebPages + { + private readonly ScriptInjector _scriptInjector; + private readonly ILoggerFactory _loggerFactory; + public IServiceProvider ServiceProvider { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public MediaBarEnhancedPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILoggerFactory loggerFactory) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + _loggerFactory = loggerFactory; + _scriptInjector = new ScriptInjector(applicationPaths, loggerFactory.CreateLogger()); + + if (Configuration.IsEnabled) + { + _scriptInjector.Inject(); + } + else + { + _scriptInjector.Remove(); + } + } + + /// + public override void UpdateConfiguration(BasePluginConfiguration configuration) + { + var oldConfig = Configuration; + base.UpdateConfiguration(configuration); + + if (Configuration.IsEnabled && !oldConfig.IsEnabled) + { + _scriptInjector.Inject(); + } + else if (!Configuration.IsEnabled && oldConfig.IsEnabled) + { + _scriptInjector.Remove(); + } + } + + /// + public override string Name => "Media Bar"; + + /// + public override Guid Id => Guid.Parse("d7e11d57-819b-4bdd-a88d-53c5f5560225"); + + /// + /// Gets the current plugin instance. + /// + public static MediaBarEnhancedPlugin? Instance { get; private set; } + + /// + public IEnumerable GetPages() + { + return + [ + new PluginPageInfo + { + Name = Name, + EnableInMainMenu = true, + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace) + } + ]; + } + + } +} diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Model/PatchRequestPayload.cs b/Jellyfin.Plugin.MediaBarEnhanced/Model/PatchRequestPayload.cs new file mode 100644 index 0000000..cbf3f8d --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/Model/PatchRequestPayload.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MediaBarEnhanced.Model +{ + public class PatchRequestPayload + { + [JsonPropertyName("contents")] + public string? Contents { get; set; } + } +} diff --git a/Jellyfin.Plugin.MediaBarEnhanced/ScriptInjector.cs b/Jellyfin.Plugin.MediaBarEnhanced/ScriptInjector.cs new file mode 100644 index 0000000..6ed41fa --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/ScriptInjector.cs @@ -0,0 +1,240 @@ +using System; +using System.Reflection; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Loader; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using MediaBrowser.Common.Configuration; +using Jellyfin.Plugin.MediaBarEnhanced.Helpers; + +namespace Jellyfin.Plugin.MediaBarEnhanced +{ + /// + /// Handles the injection of the MediaBarEnhanced script into the Jellyfin web interface. + /// + public class ScriptInjector + { + private readonly IApplicationPaths _appPaths; + private readonly ILogger _logger; + public const string ScriptTag = ""; + public const string CssTag = ""; + // private const string ScriptTag = ""; + // private const string CssTag = ""; + public const string ScriptMarker = ""; + public const string CssMarker = ""; + + /// + /// Initializes a new instance of the class. + /// + /// The application paths. + /// The logger. + public ScriptInjector(IApplicationPaths appPaths, ILogger logger) + { + _appPaths = appPaths; + _logger = logger; + } + + /// + /// Injects the script tag into index.html if it's not already present. + /// + /// True if injection was successful or already present, false otherwise. + public void Inject() + { + try + { + var webPath = GetWebPath(); + if (string.IsNullOrEmpty(webPath)) + { + _logger.LogWarning("Could not find Jellyfin web path. Script injection skipped. Attempting fallback."); + RegisterFileTransformation(); + return; + } + + var indexPath = Path.Combine(webPath, "index.html"); + if (!File.Exists(indexPath)) + { + _logger.LogWarning("index.html not found at {Path}. Script injection skipped. Attempting fallback.", indexPath); + RegisterFileTransformation(); + return; + } + + var content = File.ReadAllText(indexPath); + var injectedJS = false; + var injectedCSS = false; + + if (!content.Contains(ScriptTag)) + { + var index = content.IndexOf(ScriptMarker, StringComparison.OrdinalIgnoreCase); + if (index != -1) + { + content = content.Insert(index, ScriptTag + Environment.NewLine); + injectedJS = true; + } + } + + if (!content.Contains(CssTag)) + { + var index = content.IndexOf(CssMarker, StringComparison.OrdinalIgnoreCase); + if (index != -1) + { + content = content.Insert(index, CssTag + Environment.NewLine); + injectedCSS = true; + } + } + + if (injectedJS && injectedCSS) + { + File.WriteAllText(indexPath, content); + _logger.LogInformation("MediaBarEnhanced script injected into index.html."); + } else if (injectedJS) + { + File.WriteAllText(indexPath, content); + _logger.LogInformation("MediaBarEnhanced JS script injected into index.html. But CSS was already present or could not be injected."); + } + else if (injectedCSS) + { + File.WriteAllText(indexPath, content); + _logger.LogInformation("MediaBarEnhanced CSS injected into index.html. But JS script was already present or could not be injected."); + } + else + { + _logger.LogInformation("MediaBarEnhanced script and CSS already present in index.html. Or could not be injected."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error injecting MediaBarEnhanced resources. Attempting fallback."); + RegisterFileTransformation(); + } + } + + /// + /// Removes the script tag from index.html. + /// + public void Remove() + { + UnregisterFileTransformation(); + + try + { + var webPath = GetWebPath(); + if (string.IsNullOrEmpty(webPath)) + { + return; + } + + var indexPath = Path.Combine(webPath, "index.html"); + if (!File.Exists(indexPath)) + { + return; + } + + var content = File.ReadAllText(indexPath); + var modified = false; + + if (content.Contains(ScriptTag)) + { + content = content.Replace(ScriptTag + Environment.NewLine, "").Replace(ScriptTag, ""); + modified = true; + } + + if (content.Contains(CssTag)) + { + content = content.Replace(CssTag + Environment.NewLine, "").Replace(CssTag, ""); + modified = true; + } + + if (modified) + { + File.WriteAllText(indexPath, content); + _logger.LogInformation("MediaBarEnhanced script removed from index.html."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing MediaBarEnhanced script."); + } + } + + private string? GetWebPath() + { + var prop = _appPaths.GetType().GetProperty("WebPath", BindingFlags.Instance | BindingFlags.Public); + return prop?.GetValue(_appPaths) as string; + } + + private void RegisterFileTransformation() + { + _logger.LogInformation("MediaBarEnhanced Fallback. Registering file transformations."); + + List payloads = new List(); + + { + JObject payload = new JObject(); + // Random GUID for ID + payload.Add("id", "0dfac9d7-d898-4944-900b-1c1837707279"); + payload.Add("fileNamePattern", "index.html"); + payload.Add("callbackAssembly", GetType().Assembly.FullName); + payload.Add("callbackClass", typeof(TransformationPatches).FullName); + payload.Add("callbackMethod", nameof(TransformationPatches.IndexHtml)); + + payloads.Add(payload); + } + + Assembly? fileTransformationAssembly = + AssemblyLoadContext.All.SelectMany(x => x.Assemblies).FirstOrDefault(x => + x.FullName?.Contains(".FileTransformation") ?? false); + + if (fileTransformationAssembly != null) + { + Type? pluginInterfaceType = fileTransformationAssembly.GetType("Jellyfin.Plugin.FileTransformation.PluginInterface"); + + if (pluginInterfaceType != null) + { + foreach (JObject payload in payloads) + { + pluginInterfaceType.GetMethod("RegisterTransformation")?.Invoke(null, new object?[] { payload }); + } + _logger.LogInformation("File transformations registered successfully."); + } + else + { + _logger.LogWarning("FileTransformation plugin found but PluginInterface type missing."); + } + } + else + { + _logger.LogWarning("FileTransformation plugin assembly not found. Fallback failed."); + } + } + + private void UnregisterFileTransformation() + { + try + { + Assembly? fileTransformationAssembly = + AssemblyLoadContext.All.SelectMany(x => x.Assemblies).FirstOrDefault(x => + x.FullName?.Contains(".FileTransformation") ?? false); + + if (fileTransformationAssembly != null) + { + Type? pluginInterfaceType = fileTransformationAssembly.GetType("Jellyfin.Plugin.FileTransformation.PluginInterface"); + + if (pluginInterfaceType != null) + { + // The ID must match the one used in RegisterFileTransformation + Guid id = Guid.Parse("0dfac9d7-d898-4944-900b-1c1837707279"); + pluginInterfaceType.GetMethod("RemoveTransformation")?.Invoke(null, new object?[] { id }); + _logger.LogInformation("File transformation unregistered successfully."); + } + } + } + catch (Exception ex) + { + // Log but don't throw, as we want to continue with normal removal + _logger.LogWarning(ex, "Error attempting to unregister file transformation. It might not have been registered."); + } + } + } +} diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Web/slideshowpure.css b/Jellyfin.Plugin.MediaBarEnhanced/Web/slideshowpure.css new file mode 100644 index 0000000..a3a9d11 --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/Web/slideshowpure.css @@ -0,0 +1,948 @@ +/* + * Jellyfin Slideshow by M0RPH3US v3.0.6 + * Modified by CodeDevMLH v1.1.0.0 + * + * New features: + * - optional Trailer background video support + * - option to make video backdrops full width + * - SponsorBlock support to skip intro/outro segments + * - option to always show arrows + * - option to disable/enable keyboard controls + * - option to show/hide trailer button if trailer as backdrop is disabled (opens in a modal) + * - option to wait for trailer to end before loading next slide + * - option to set a maximum for the pagination dots (will turn into a counter style if exceeded) + * - option to disable loading screen + * - option to put collection (boxsets) IDs into the slideshow to display their items + */ + +@import url(https://fonts.googleapis.com/css2?family=Archivo+Narrow:ital,wght@0,400..700;1,400..700&display=swap); + +.backdrop.animate { + animation: + frostedGlass 1.2s cubic-bezier(0.4, 0, 0.2, 1), + kenBurnsZoomIn 10s ease-out forwards; +} + +.logo.animate { + animation: frostedGlass 1.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes frostedGlass { + from { + filter: blur(8px); + opacity: 0.7; + } + + to { + filter: blur(0); + opacity: 1; + } +} + +@keyframes kenBurnsZoomIn { + from { + transform: scale(1); + } + + to { + transform: scale(1.1); + } +} + +.bar-loading { + z-index: 99999999 !important; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: #000; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + transition: opacity 0.3s ease-in-out; + overflow: hidden; + /*will-change: opacity;*/ +} + +.bar-loading.hide { + opacity: 0; +} + +.loader-content { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + width: 250px; + height: auto; +} + +.bar-loading h1 { + width: 250px; + margin: 0 auto; + height: 250px; + display: flex; + justify-content: center; + align-items: center; +} + +.bar-loading h1 div { + width: 250px; + max-height: 250px; + display: block; + object-fit: contain; + opacity: 0; + transition: opacity 0.5s ease-in-out; +} + +.progress-container { + width: 200px; + height: 6px; + display: flex; + align-items: center; + position: relative; +} + +.progress-bar { + height: 5px; + background: white; + border-radius: 2px; + transition: width 0.2s ease-in-out; +} + +.progress-gap { + width: 6px; + height: 5px; + background: transparent; + flex-shrink: 0; +} + +.unfilled-bar { + height: 5px; + background: #686868; + border-radius: 2px; + flex-grow: 1; + transition: width 0.2s ease-in-out; +} + +.backdrop.low-quality { + filter: blur(0.5px); + transform: scale(1.01); + transition: + filter 0.3s ease-in-out, + transform 0.3s ease-in-out; +} + +.backdrop.high-quality { + filter: blur(0); + transform: scale(1); + transition: + filter 0.3s ease-in-out, + transform 0.3s ease-in-out; +} + +.logo.low-quality { + filter: brightness(1) blur(0.5px); + transition: filter 0.3s ease-in-out; +} + +.logo.high-quality { + filter: brightness(1.1) blur(0); + transition: filter 0.3s ease-in-out; +} + +.homeSectionsContainer { + position: relative; + top: 65vh; + z-index: 6; +} + +#slides-container { + position: relative; + width: 100vw; + height: 90%; + overflow: hidden; + margin: 0 auto; +} + +.arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + font-size: 24px; + cursor: pointer; + color: #fff; + z-index: 5; + width: 40px; + height: 40px; + border-radius: 50%; + display: block; + text-align: center; + -webkit-tap-highlight-color: #fff0; + transition: background-color 0.3s ease, transform 0.2s ease; + user-select: none; + margin: 0; + padding: 0; +} + +.arrow i { + display: block; + font-size: 24px; + line-height: 40px; + width: 100%; + height: 100%; + text-align: center; + margin: 0; + padding: 0; +} + +.arrow:hover { + background-color: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + transform: translateY(-50%) scale(1.1); +} + +.left-arrow { + left: 20px; +} + +.right-arrow { + right: 20px; +} + +.pause-button { + position: absolute; + top: 5rem; + right: 0.8rem; + cursor: pointer; + color: white; + z-index: 10; + opacity: 0.3; + transition: opacity 0.3s ease; + text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8); +} + +.pause-button i { + font-size: 1.5rem; +} + +.pause-button:hover { + opacity: 0.9; +} + +@media (max-width: 1599px) { + .pause-button { + top: 8rem; + } + + .pause-button i { + font-size: 2rem; + } +} + +@media (max-width: 768px) { + .pause-button i { + font-size: 2.5rem; + } +} + +.mute-button { + position: absolute; + top: 5rem; + right: 3.5rem; + cursor: pointer; + color: white; + z-index: 10; + opacity: 0.3; + transition: opacity 0.3s ease; + text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8); +} + +.mute-button i { + font-size: 1.5rem; +} + +.mute-button:hover { + opacity: 0.9; +} + +@media (max-width: 1599px) { + .mute-button { + top: 8rem; + } + + .mute-button i { + font-size: 2rem; + } +} + +@media (max-width: 768px) { + .mute-button i { + font-size: 2.5rem; + } +} + +.dots-container { + position: absolute; + top: calc(50% + 18vh); + right: 3%; + z-index: 5; + display: flex; + justify-content: center; + align-items: center; + width: auto; + height: auto; + transition: opacity 0.3s ease-in-out; +} + +.dot { + display: inline-block; + width: 0.5em; + height: 0.5em; + margin: 0 5px; + background-color: #cecece99; + border-radius: 50%; + transform-origin: center; + transition: + transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.5s ease-in-out; +} + +.dot.active { + background-color: #fff; + transform: scale(1.7); +} + +.slide { + opacity: 0; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transition: opacity 0.5s ease-in-out; + z-index: 0; +} + +.slide.active { + opacity: 1; + z-index: 1; +} + +.backdrop-container { + position: absolute; + top: 0%; + right: 0%; + width: 100%; + height: 100%; + mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 6%, + #000000 8%); + -webkit-mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 6%, + #000000 8%); +} + +.backdrop-container.full-width-video { + overflow: hidden; + mask-image: linear-gradient(to top, + #fff0 0%, + #fff0 10%, + #000 25%); + -webkit-mask-image: linear-gradient(to top, + #fff0 0%, + #fff0 10%, + #000 25%); +} + +.backdrop { + right: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 20%; + border-radius: 5px; + z-index: 3; + mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 6%, + #000000 8%); + -webkit-mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 6%, + #000000 8%); +} + +.backdrop-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 5px; + z-index: 4; + mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 4%, + #000000 6%); + -webkit-mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 4%, + #000000 6%); +} + +.gradient-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(130deg, + rgba(29, 29, 29, 0.65) 10%, + rgba(29, 29, 29, 0.35) 30%, + rgba(29, 29, 29, 0) 100%); + z-index: 4; + mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 4%, + #000000 6%); + -webkit-mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 4%, + #000000 6%); +} + +.gradient-overlay.full-width-video { + z-index: 4; + mask-image: linear-gradient(to top, + #fff0 0%, + #fff0 10%, + #000 25%); + -webkit-mask-image: linear-gradient(to top, + #fff0 0%, + #fff0 10%, + #000 25%); +} + +.logo-container { + width: 40%; + height: 35%; + position: relative; + display: flex; + align-items: center; + z-index: 5; + top: 15vh; + left: 4vw; +} + +.logo { + max-height: 70%; + max-width: 100%; + height: auto; + width: auto; + object-fit: contain; + filter: brightness(1.5); + font-size: 4rem; +} + +.plot-container { + position: absolute; + top: calc(50% + 8vh); + color: #fff; + height: 15%; + width: 90%; + left: 4vw; + z-index: 5; + display: flex; + align-items: flex-start; + justify-content: flex-start; + text-align: justify; + box-sizing: border-box; +} + +.plot { + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.genre { + display: flex; + gap: 5px; + align-items: center; + justify-content: center; + position: absolute; + top: calc(50% + 4vh); + left: 4vw; + text-align: center; + color: #fff; + font-family: "Archivo Narrow", sans-serif; + z-index: 5; + flex-wrap: wrap; +} + +.button-container { + position: absolute; + top: calc(50% + 17vh); + left: 4vw; + display: flex; + z-index: 5; + justify-content: space-between; + gap: 15px; +} + +.play-button, +.trailer-button { + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: center; + padding: 8px 16px; + border: solid 0px rgba(255, 255, 255, 0); + font-family: "Archivo Narrow", sans-serif; + font-size: 18px; + white-space: nowrap; + cursor: pointer; + transition: all 0.3s ease; + font-weight: 700; + gap: 6px; + -webkit-tap-highlight-color: #fff0; + border-radius: 8px; +} + +.detail-button { + font-size: 18px; + color: rgb(0, 0, 0); + border-radius: 50%; + height: 50px; + width: 50px; + border: none; + cursor: pointer; + transition: color 0.2s; + -webkit-tap-highlight-color: #fff0; +} + +.favorite-button { + font-size: 18px; + color: red; + border-radius: 50%; + height: 50px; + width: 50px; + border: none; + cursor: pointer; + transition: color 0.2s; + -webkit-tap-highlight-color: #fff0; +} + +.favorite-button.favorited { + color: red; +} + +.favorite-button::before { + content: "favorite_outline"; + font-family: "Material Icons"; +} + +.favorite-button.favorited::before { + content: "favorite"; + font-family: "Material Icons"; +} + +.play-button::before { + content: "play_arrow"; + font-family: "Material Icons"; +} + +.detail-button::before { + content: "info_outline"; + font-family: "Material Icons"; +} + +.play-button::before, +.detail-button::before, +.favorite-button::before, +.favorite-button.favorited::before { + font-weight: 600; + display: inline-block; + font-size: 22px; + color: inherit; + vertical-align: middle; +} + +.play-button:hover, +.detail-button:hover, +.favorite-button:hover, +.trailer-button:hover { + opacity: 0.8; +} + +.info-container { + position: absolute; + top: calc(50% + 0vh); + display: flex; + align-items: center; + justify-content: center; + left: 4vw; + color: #fff; + z-index: 5; + align-content: center; + flex-wrap: wrap; + font-weight: 500; +} + +.misc-info { + font-family: "Archivo Narrow", sans-serif; + display: flex; + align-items: center; + z-index: 5; + position: relative; + gap: 10px; +} + +.runTime { + font-family: "Archivo Narrow", sans-serif; + display: flex; + align-items: center; + align-content: center; + justify-content: center; + flex-wrap: wrap; + font-weight: 500; +} + +/* Star Icon Styling */ +.community-rating-star { + color: #f2b01e; + font-size: 1.4em; + height: auto !important; + margin-right: 0.125em; + width: auto !important; +} + +.star-rating-container { + display: flex; + align-items: center; +} + +/* Rotten Tomatoes Critic Rating Styles */ + +.critic-rating { + -webkit-align-items: center; + align-items: center; + display: flex; + min-height: 1.2em; + gap: 0.25em; +} + +/**/ + +.age-rating { + display: flex; + align-items: center; + border-radius: 5px; + background: rgb(255 255 255 / 0.8); + color: #000; + border: none; + font-weight: 600; + white-space: nowrap; + padding: 0 0.5em; +} + +.date { + font-family: "Archivo Narrow", sans-serif; + font-weight: 500; + display: flex; + align-items: center; + flex-wrap: wrap; + align-content: center; + justify-content: center; +} + +.separator-icon { + font-size: 10px; + color: aquamarine; +} + +.featured-content { + display: none; +} + +/*Portrait-Modes Phone*/ +@media only screen and (max-width: 767px) and (orientation: portrait) { + .plot-container { + display: none; + } + + .backdrop-container { + position: absolute; + right: 0%; + width: 100%; + height: 100%; + } + + .backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 20%; + z-index: 3; + mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 6%, + #000000 8%); + -webkit-mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 6%, + #000000 8%); + } + + .gradient-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgb(0 0 0 / 0.25); + z-index: 4; + pointer-events: none; + mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 6%, + #000000 8%); + -webkit-mask-image: linear-gradient(to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 6%, + #000000 8%); + } + + .dots-container { + top: calc(50% + 20vh); + left: 50%; + transform: translateX(-50%) scale(0.8); + background-color: #ffffff00; + } + + .dot.active { + transform: scale(1.6); + } + + .genre { + top: calc(50% + 15vh); + left: 50%; + width: 100%; + transform: translateX(-50%); + } + + .info-container { + top: calc(50% + 10vh); + left: 50%; + transform: translateX(-50%); + width: 95%; + } + + .button-container { + top: calc(50% + 25vh); + left: 50%; + transform: translateX(-50%) scale(0.95); + } + + .logo { + position: absolute; + top: 50%; + left: 50%; + max-height: 60%; + max-width: 100%; + width: auto; + z-index: 5; + filter: brightness(1.5); + transform: translate(-50%, -50%); + transition: filter 0.3s ease; + } + + .logo-container { + width: 75%; + height: 25%; + position: relative; + display: flex; + align-items: start; + z-index: 5; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } +} + +/*Landscape Mode Phones*/ +@media only screen and (max-height: 767px) and (orientation: landscape) { + #slides-container { + height: 100%; + } + + .homeSectionsContainer { + top: 57vh; + } + + .button-container { + left: 3vw; + transform: scale(0.85); + } + + .dots-container { + scale: 0.6; + } + + .info-container { + top: calc(50% + -10vh); + } + + .plot-container { + top: calc(50% + 6vh); + } + + .genre { + top: calc(50% + -1vh); + } + + .logo-container { + height: 30%; + top: 10%; + } + + .logo-container, + .info-container, + .genre, + .plot-container { + left: 5vw; + } +} + +@media only screen and (min-width: 2560px) { + .button-container { + top: calc(50% + 15vh); + } + + .dots-container { + top: calc(50% + 15vh); + } +} + +/* Video Modal Styles */ +#video-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 9999; + display: flex; + justify-content: center; + align-items: center; +} + +.modal-close-button { + position: absolute; + top: 20px; + right: 20px; + background: transparent; + border: none; + color: white; + font-size: 2rem; + cursor: pointer; + z-index: 10000; +} + +.video-modal-content { + width: 80%; + height: 80%; + position: relative; + max-width: 1280px; + max-height: 720px; +} + +#modal-yt-player, +.video-modal-player { + width: 100%; + height: 100%; +} + +.video-modal-player { + object-fit: contain; +} + +@media (orientation: portrait) { + .video-modal-content { + width: 95%; + height: auto; + aspect-ratio: 16/9; + } +} + +@media (orientation: landscape) { + .video-modal-content { + height: 95%; + width: auto; + aspect-ratio: 16/9; + max-width: 95%; + } +} + + +/* Video Backdrop Styles */ +.video-backdrop { + pointer-events: none; + object-fit: cover; +} + +.video-backdrop-default { + width: 100%; + height: 100%; +} + +.video-backdrop-full { + width: 100vw; + height: 56.25vw; + min-height: 100vh; + min-width: 177.77vh; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +/* Slide Counter Styling */ +.slide-counter { + font-family: "Archivo Narrow", sans-serif; + color: #fff; + font-size: 1.2rem; + font-weight: 600; + letter-spacing: 1px; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.4); + padding: 5px 16px; + border-radius: 30px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.15); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + text-align: center; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + + +.dots-container .slide-counter { + margin: 0; +} \ No newline at end of file diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Web/slideshowpure.js b/Jellyfin.Plugin.MediaBarEnhanced/Web/slideshowpure.js new file mode 100644 index 0000000..cf1db63 --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/Web/slideshowpure.js @@ -0,0 +1,2865 @@ +/* + * Jellyfin Slideshow by M0RPH3US v3.0.6 + * Modified by CodeDevMLH v1.1.0.0 + * + * New features: + * - optional Trailer background video support + * - option to make video backdrops full width + * - SponsorBlock support to skip intro/outro segments + * - option to always show arrows + * - option to disable/enable keyboard controls + * - option to show/hide trailer button if trailer as backdrop is disabled (opens in a modal) + * - option to wait for trailer to end before loading next slide + * - option to set a maximum for the pagination dots (will turn into a counter style if exceeded) + * - option to disable loading screen + * - option to put collection (boxsets) IDs into the slideshow to display their items + */ + +//Core Module Configuration +const CONFIG = { + IMAGE_SVG: { + freshTomato: + 'image/svg+xml', + rottenTomato: + '', + }, + shuffleInterval: 7000, + retryInterval: 500, + minSwipeDistance: 50, + loadingCheckInterval: 100, + maxPlotLength: 360, + maxMovies: 15, + maxTvShows: 15, + maxItems: 500, + preloadCount: 3, + fadeTransitionDuration: 500, + maxPaginationDots: 15, + slideAnimationEnabled: true, + enableVideoBackdrop: true, + useSponsorBlock: true, + waitForTrailerToEnd: true, + startMuted: true, + fullWidthVideo: true, + enableMobileVideo: false, + showTrailerButton: true, + enableKeyboardControls: true, + alwaysShowArrows: false, + enableCustomMediaIds: false, + enableSeasonalContent: false, + customMediaIds: "", + enableLoadingScreen: true, +}; + +// State management +const STATE = { + jellyfinData: { + userId: null, + appName: null, + appVersion: null, + deviceName: null, + deviceId: null, + accessToken: null, + serverAddress: null, + }, + slideshow: { + hasInitialized: false, + isTransitioning: false, + isPaused: false, + currentSlideIndex: 0, + focusedSlide: null, + containerFocused: false, + slideInterval: null, + itemIds: [], + loadedItems: {}, + createdSlides: {}, + totalItems: 0, + isLoading: false, + videoPlayers: {}, + sponsorBlockInterval: null, + isMuted: CONFIG.startMuted, + }, +}; + +// Request throttling system +const requestQueue = []; +let isProcessingQueue = false; + +/** + * Process the next request in the queue with throttling + */ +const processNextRequest = () => { + if (requestQueue.length === 0) { + isProcessingQueue = false; + return; + } + + isProcessingQueue = true; + const { url, callback } = requestQueue.shift(); + + fetch(url) + .then((response) => { + if (response.ok) { + return response; + } + throw new Error(`Failed to fetch: ${response.status}`); + }) + .then(callback) + .catch((error) => { + console.error("Error in throttled request:", error); + }) + .finally(() => { + setTimeout(processNextRequest, 100); + }); +}; + +/** + * Add a request to the throttled queue + * @param {string} url - URL to fetch + * @param {Function} callback - Callback to run on successful fetch + */ +const addThrottledRequest = (url, callback) => { + requestQueue.push({ url, callback }); + if (!isProcessingQueue) { + processNextRequest(); + } +}; + +/** + * Checks if the user is currently logged in + * @returns {boolean} True if logged in, false otherwise + */ + +const isUserLoggedIn = () => { + try { + return ( + window.ApiClient && + window.ApiClient._currentUser && + window.ApiClient._currentUser.Id && + window.ApiClient._serverInfo && + window.ApiClient._serverInfo.AccessToken + ); + } catch (error) { + console.error("Error checking login status:", error); + return false; + } +}; + +/** + * Initializes Jellyfin data from ApiClient + * @param {Function} callback - Function to call once data is initialized + */ +const initJellyfinData = (callback) => { + if (!window.ApiClient) { + console.warn("⏳ window.ApiClient is not available yet. Retrying..."); + setTimeout(() => initJellyfinData(callback), CONFIG.retryInterval); + return; + } + + try { + const apiClient = window.ApiClient; + STATE.jellyfinData = { + userId: apiClient.getCurrentUserId() || "Not Found", + appName: apiClient._appName || "Not Found", + appVersion: apiClient._appVersion || "Not Found", + deviceName: apiClient._deviceName || "Not Found", + deviceId: apiClient._deviceId || "Not Found", + accessToken: apiClient._serverInfo.AccessToken || "Not Found", + serverId: apiClient._serverInfo.Id || "Not Found", + serverAddress: apiClient._serverAddress || "Not Found", + }; + if (callback && typeof callback === "function") { + callback(); + } + } catch (error) { + console.error("Error initializing Jellyfin data:", error); + setTimeout(() => initJellyfinData(callback), CONFIG.retryInterval); + } +}; + +/** + * Initializes localization by loading translation chunks + */ +const initLocalization = async () => { + try { + const locale = await LocalizationUtils.getCurrentLocale(); + await LocalizationUtils.loadTranslations(locale); + console.log("✅ Localization initialized"); + } catch (error) { + console.error("Error initializing localization:", error); + } +}; + +/** + * Creates and displays loading screen + */ + +const initLoadingScreen = () => { + const currentPath = window.location.href.toLowerCase().replace(window.location.origin, ""); + const isHomePage = + currentPath.includes("/web/#/home.html") || + currentPath.includes("/web/#/home") || + currentPath.includes("/web/index.html#/home.html") || + currentPath === "/web/index.html#/home" || + currentPath.endsWith("/web/"); + + if (!isHomePage) return; + + // Check LocalStorage for cached preference to avoid flash + const cachedSetting = localStorage.getItem('mediaBarEnhanced_enableLoadingScreen'); + if (cachedSetting === 'false') { + return; + } + + const loadingDiv = document.createElement("div"); + loadingDiv.className = "bar-loading"; + loadingDiv.id = "page-loader"; + loadingDiv.innerHTML = ` +
+

+ +

+
+
+
+
+
+
+ `; + document.body.appendChild(loadingDiv); + + requestAnimationFrame(() => { + document.querySelector(".bar-loading h1 div").style.opacity = "1"; + }); + + const progressBar = document.getElementById("progress-bar"); + const unfilledBar = document.getElementById("unfilled-bar"); + + let progress = 0; + let lastIncrement = 5; + + const progressInterval = setInterval(() => { + if (progress < 95) { + lastIncrement = Math.max(0.5, lastIncrement * 0.98); + const randomFactor = 0.8 + Math.random() * 0.4; + const increment = lastIncrement * randomFactor; + progress += increment; + progress = Math.min(progress, 95); + + progressBar.style.width = `${progress}%`; + unfilledBar.style.width = `${100 - progress}%`; + } + }, 150); + + const checkInterval = setInterval(() => { + const loginFormLoaded = document.querySelector(".manualLoginForm"); + const homePageLoaded = + document.querySelector(".homeSectionsContainer") && + document.querySelector("#slides-container"); + + if (loginFormLoaded || homePageLoaded) { + clearInterval(progressInterval); + clearInterval(checkInterval); + + progressBar.style.transition = "width 300ms ease-in-out"; + progressBar.style.width = "100%"; + unfilledBar.style.width = "0%"; + + progressBar.addEventListener('transitionend', () => { + requestAnimationFrame(() => { + const loader = document.querySelector(".bar-loading"); + if (loader) { + loader.style.opacity = '0'; + setTimeout(() => { + loader.remove(); + }, 300); + } + }); + }) + } + }, CONFIG.loadingCheckInterval); +}; + +/** + * Resets the slideshow state completely + */ +const resetSlideshowState = () => { + console.log("🔄 Resetting slideshow state..."); + + if (STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.stop(); + } + + // Destroy all video players + if (STATE.slideshow.videoPlayers) { + Object.values(STATE.slideshow.videoPlayers).forEach(player => { + if (player && typeof player.destroy === 'function') { + player.destroy(); + } + }); + STATE.slideshow.videoPlayers = {}; + } + + if (STATE.slideshow.sponsorBlockInterval) { + clearInterval(STATE.slideshow.sponsorBlockInterval); + STATE.slideshow.sponsorBlockInterval = null; + } + + const container = document.getElementById("slides-container"); + if (container) { + while (container.firstChild) { + container.removeChild(container.firstChild); + } + } + + STATE.slideshow.hasInitialized = false; + STATE.slideshow.isTransitioning = false; + STATE.slideshow.isPaused = false; + STATE.slideshow.currentSlideIndex = 0; + STATE.slideshow.focusedSlide = null; + STATE.slideshow.containerFocused = false; + STATE.slideshow.slideInterval = null; + STATE.slideshow.itemIds = []; + STATE.slideshow.loadedItems = {}; + STATE.slideshow.createdSlides = {}; + STATE.slideshow.totalItems = 0; + STATE.slideshow.isLoading = false; +}; + +/** + * Watches for login status changes + */ +const startLoginStatusWatcher = () => { + let wasLoggedIn = false; + + setInterval(() => { + const isLoggedIn = isUserLoggedIn(); + + if (isLoggedIn !== wasLoggedIn) { + if (isLoggedIn) { + console.log("👤 User logged in. Initializing slideshow..."); + if (!STATE.slideshow.hasInitialized) { + waitForApiClientAndInitialize(); + } else { + console.log("🔄 Slideshow already initialized, skipping"); + } + } else { + console.log("👋 User logged out. Stopping slideshow..."); + resetSlideshowState(); + } + wasLoggedIn = isLoggedIn; + } + }, 2000); +}; + +/** + * Wait for ApiClient to initialize before starting the slideshow + */ +const waitForApiClientAndInitialize = () => { + if (window.slideshowCheckInterval) { + clearInterval(window.slideshowCheckInterval); + } + + window.slideshowCheckInterval = setInterval(() => { + if (!window.ApiClient) { + console.log("⏳ ApiClient not available yet. Waiting..."); + return; + } + + if ( + window.ApiClient._currentUser && + window.ApiClient._currentUser.Id && + window.ApiClient._serverInfo && + window.ApiClient._serverInfo.AccessToken + ) { + console.log( + "🔓 User is fully logged in. Starting slideshow initialization..." + ); + clearInterval(window.slideshowCheckInterval); + + if (!STATE.slideshow.hasInitialized) { + initJellyfinData(async () => { + console.log("✅ Jellyfin API client initialized successfully"); + await initLocalization(); + await fetchPluginConfig(); + slidesInit(); + }); + } else { + console.log("🔄 Slideshow already initialized, skipping"); + } + } else { + console.log( + "🔒 Authentication incomplete. Waiting for complete login..." + ); + } + }, CONFIG.retryInterval); +}; + +const fetchPluginConfig = async () => { + try { + const response = await fetch('/MediaBarEnhanced/Config'); + if (response.ok) { + const pluginConfig = await response.json(); + if (pluginConfig) { + for (const key in pluginConfig) { + const camelKey = key.charAt(0).toLowerCase() + key.slice(1); + if (CONFIG.hasOwnProperty(camelKey)) { + CONFIG[camelKey] = pluginConfig[key]; + } + } + STATE.slideshow.isMuted = CONFIG.startMuted; + + if (!CONFIG.enableLoadingScreen) { + const loader = document.querySelector(".bar-loading"); + if (loader) { + loader.remove(); + } + } + + // Sync to LocalStorage for next load + localStorage.setItem('mediaBarEnhanced_enableLoadingScreen', CONFIG.enableLoadingScreen); + + console.log("✅ MediaBarEnhanced config loaded", CONFIG); + } + } + } catch (e) { + console.error("Failed to load MediaBarEnhanced config", e); + } +}; + +waitForApiClientAndInitialize(); + +/** + * Utility functions for slide creation and management + */ +const SlideUtils = { + /** + * Shuffles array elements randomly + * @param {Array} array - Array to shuffle + * @returns {Array} Shuffled array + */ + shuffleArray(array) { + const newArray = [...array]; + for (let i = newArray.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; + } + return newArray; + }, + + /** + * Truncates text to specified length and adds ellipsis + * @param {HTMLElement} element - Element containing text to truncate + * @param {number} maxLength - Maximum length before truncation + */ + truncateText(element, maxLength) { + if (!element) return; + + const text = element.innerText || element.textContent; + if (text && text.length > maxLength) { + element.innerText = text.substring(0, maxLength) + "..."; + } + }, + + /** + * Creates a separator icon element + * @returns {HTMLElement} Separator element + */ + createSeparator() { + const separator = document.createElement("i"); + separator.className = "material-icons fiber_manual_record separator-icon"; //material-icons radio_button_off + return separator; + }, + + /** + * Creates a DOM element with attributes and properties + * @param {string} tag - Element tag name + * @param {Object} attributes - Element attributes + * @param {string|HTMLElement} [content] - Element content + * @returns {HTMLElement} Created element + */ + createElement(tag, attributes = {}, content = null) { + const element = document.createElement(tag); + + Object.entries(attributes).forEach(([key, value]) => { + if (key === "style" && typeof value === "object") { + Object.entries(value).forEach(([prop, val]) => { + element.style[prop] = val; + }); + } else if (key === "className") { + element.className = value; + } else if (key === "innerHTML") { + element.innerHTML = value; + } else if (key === "onclick" && typeof value === "function") { + element.addEventListener("click", value); + } else { + element.setAttribute(key, value); + } + }); + + if (content) { + if (typeof content === "string") { + element.textContent = content; + } else { + element.appendChild(content); + } + } + + return element; + }, + + /** + * Find or create the slides container + * @returns {HTMLElement} Slides container element + */ + getOrCreateSlidesContainer() { + let container = document.getElementById("slides-container"); + if (!container) { + container = this.createElement("div", { id: "slides-container" }); + document.body.appendChild(container); + } + return container; + }, + + /** + * Formats genres into a readable string + * @param {Array} genresArray - Array of genre strings + * @returns {string} Formatted genres string + */ + parseGenres(genresArray) { + if (Array.isArray(genresArray) && genresArray.length > 0) { + return genresArray.slice(0, 3).join(this.createSeparator().outerHTML); + } + return "No Genre Available"; + }, + + /** + * Creates a loading indicator + * @returns {HTMLElement} Loading indicator element + */ + createLoadingIndicator() { + const loadingIndicator = this.createElement("div", { + className: "slide-loading-indicator", + innerHTML: ` +
+
+
+
+
+ `, + }); + return loadingIndicator; + }, + + /** + * Loads the YouTube IFrame API if not already loaded + * @returns {Promise} + */ + loadYouTubeIframeAPI() { + return new Promise((resolve) => { + if (window.YT && window.YT.Player) { + resolve(); + return; + } + + const tag = document.createElement('script'); + tag.src = "https://www.youtube.com/iframe_api"; + const firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); + + const previousOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + if (previousOnYouTubeIframeAPIReady) previousOnYouTubeIframeAPIReady(); + resolve(); + }; + }); + }, + + /** + * Opens a modal video player + * @param {string} url - Video URL + */ + openVideoModal(url) { + const existingModal = document.getElementById('video-modal-overlay'); + if (existingModal) existingModal.remove(); + + if (STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.stop(); + } + STATE.slideshow.isPaused = true; + + const overlay = this.createElement('div', { + id: 'video-modal-overlay' + }); + + const closeModal = () => { + overlay.remove(); + STATE.slideshow.isPaused = false; + if (STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.start(); + } + }; + + const closeButton = this.createElement('button', { + className: 'modal-close-button', + innerHTML: 'close', + onclick: closeModal + }); + + const contentContainer = this.createElement('div', { + className: 'video-modal-content' + }); + + let videoId = null; + let isYoutube = false; + + try { + const urlObj = new URL(url); + if (urlObj.hostname.includes('youtube.com') || urlObj.hostname.includes('youtu.be')) { + isYoutube = true; + videoId = urlObj.searchParams.get('v'); + if (!videoId && urlObj.hostname.includes('youtu.be')) { + videoId = urlObj.pathname.substring(1); + } + } + } catch (e) { + console.warn("Invalid URL for modal:", url); + } + + if (isYoutube && videoId) { + const playerDiv = this.createElement('div', { id: 'modal-yt-player' }); + contentContainer.appendChild(playerDiv); + overlay.append(closeButton, contentContainer); + document.body.appendChild(overlay); + + this.loadYouTubeIframeAPI().then(() => { + new YT.Player('modal-yt-player', { + height: '100%', + width: '100%', + videoId: videoId, + playerVars: { + autoplay: 1, + controls: 1, + iv_load_policy: 3, + rel: 0 + } + }); + }); + } else { + const video = this.createElement('video', { + src: url, + controls: true, + autoplay: true, + className: 'video-modal-player' + }); + contentContainer.appendChild(video); + overlay.append(closeButton, contentContainer); + document.body.appendChild(overlay); + } + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + closeModal(); + } + }); + }, +}; + +/** + * Localization utilities for fetching and using Jellyfin translations + */ +const LocalizationUtils = { + translations: {}, + locale: null, + isLoading: {}, + cachedLocale: null, + chunkUrlCache: {}, + + /** + * Gets the current locale from user preference, server config, or HTML tag + * @returns {Promise} Locale code (e.g., "de", "en-us") + */ + async getCurrentLocale() { + if (this.cachedLocale) { + return this.cachedLocale; + } + + let locale = null; + + try { + if (window.ApiClient && typeof window.ApiClient.deviceId === 'function') { + const deviceId = window.ApiClient.deviceId(); + if (deviceId) { + const deviceKey = `${deviceId}-language`; + locale = localStorage.getItem(deviceKey).toLowerCase(); + } + } + if (!locale) { + locale = localStorage.getItem("language").toLowerCase(); + } + } catch (e) { + console.warn("Could not access localStorage for language:", e); + } + + if (!locale) { + const langAttr = document.documentElement.getAttribute("lang"); + if (langAttr) { + locale = langAttr.toLowerCase(); + } + } + + if (window.ApiClient && STATE.jellyfinData?.accessToken) { + try { + const userId = window.ApiClient.getCurrentUserId(); + if (userId) { + const userUrl = window.ApiClient.getUrl(`Users/${userId}`); + const userResponse = await fetch(userUrl, { + headers: ApiUtils.getAuthHeaders(), + }); + if (userResponse.ok) { + const userData = await userResponse.json(); + if (userData.Configuration?.AudioLanguagePreference) { + locale = userData.Configuration.AudioLanguagePreference.toLowerCase(); + } + } + } + } catch (error) { + console.warn("Could not fetch user audio language preference:", error); + } + } + + if (!locale && window.ApiClient && STATE.jellyfinData?.accessToken) { + try { + const configUrl = window.ApiClient.getUrl('System/Configuration'); + const configResponse = await fetch(configUrl, { + headers: ApiUtils.getAuthHeaders(), + }); + if (configResponse.ok) { + const configData = await configResponse.json(); + if (configData.PreferredMetadataLanguage) { + locale = configData.PreferredMetadataLanguage.toLowerCase(); + if (configData.MetadataCountryCode) { + locale = `${locale}-${configData.MetadataCountryCode.toLowerCase()}`; + } + } + } + } catch (error) { + console.warn("Could not fetch server metadata language preference:", error); + } + } + + if (!locale) { + const navLang = navigator.language || navigator.userLanguage; + locale = navLang ? navLang.toLowerCase() : "en-us"; + } + + // Convert 3-letter country codes to 2-letter if necessary + if (locale.length === 3) { + const countriesData = await window.ApiClient.getCountries(); + const countryData = Object.values(countriesData).find(countryData => countryData.ThreeLetterISORegionName === locale.toUpperCase()); + if (countryData) { + locale = countryData.TwoLetterISORegionName.toLowerCase(); + } + } + + this.cachedLocale = locale; + return locale; + }, + + /** + * Finds the translation chunk URL from performance entries + * @param {string} locale - Locale code + * @returns {string|null} URL to translation chunk or null + */ + findTranslationChunkUrl(locale) { + const localePrefix = locale.split('-')[0]; + + if (this.chunkUrlCache[localePrefix]) { + return this.chunkUrlCache[localePrefix]; + } + + if (window.performance && window.performance.getEntriesByType) { + try { + const resources = window.performance.getEntriesByType('resource'); + for (const resource of resources) { + const url = resource.name || resource.url; + if (url && url.includes(`${localePrefix}-json`) && url.includes('.chunk.js')) { + this.chunkUrlCache[localePrefix] = url; + return url; + } + } + } catch (e) { + console.warn("Error checking performance entries:", e); + } + } + + this.chunkUrlCache[localePrefix] = null; + return null; + }, + + /** + * Fetches and loads translations from the chunk JSON + * @param {string} locale - Locale code + * @returns {Promise} + */ + async loadTranslations(locale) { + if (this.translations[locale]) return; + if (this.isLoading[locale]) { + await this.isLoading[locale]; + return; + } + + const loadPromise = (async () => { + try { + const chunkUrl = this.findTranslationChunkUrl(locale); + if (!chunkUrl) { + return; + } + + const response = await fetch(chunkUrl); + if (!response.ok) { + throw new Error(`Failed to fetch translations: ${response.statusText}`); + } + + const chunkText = await response.text(); + + let jsonMatch = chunkText.match(/JSON\.parse\(['"](.*?)['"]\)/); + if (jsonMatch) { + let jsonString = jsonMatch[1] + .replace(/\\"/g, '"') + .replace(/\\n/g, '\n') + .replace(/\\\\/g, '\\') + .replace(/\\'/g, "'"); + try { + this.translations[locale] = JSON.parse(jsonString); + return; + } catch (e) { + // Try direct extraction + } + } + + const jsonStart = chunkText.indexOf('{'); + const jsonEnd = chunkText.lastIndexOf('}') + 1; + if (jsonStart !== -1 && jsonEnd > jsonStart) { + const jsonString = chunkText.substring(jsonStart, jsonEnd); + try { + this.translations[locale] = JSON.parse(jsonString); + } catch (e) { + console.error("Failed to parse JSON from chunk:", e); + } + } + } catch (error) { + console.error("Error loading translations:", error); + } finally { + delete this.isLoading[locale]; + } + })(); + + this.isLoading[locale] = loadPromise; + await loadPromise; + }, + + /** + * Gets a localized string (synchronous - translations must be loaded first) + * @param {string} key - Localization key (e.g., "EndsAtValue", "Play") + * @param {string} fallback - Fallback English string + * @param {...any} args - Optional arguments for placeholders (e.g., {0}, {1}) + * @returns {string} Localized string or fallback + */ + getLocalizedString(key, fallback, ...args) { + const locale = this.cachedLocale || 'en-us'; + let translated = this.translations[locale]?.[key] || fallback; + + if (args.length > 0) { + for (let i = 0; i < args.length; i++) { + translated = translated.replace(new RegExp(`\\{${i}\\}`, 'g'), args[i]); + } + } + + return translated; + } +}; + +/** + * API utilities for fetching data from Jellyfin server + */ +const ApiUtils = { + /** + * Fetches details for a specific item by ID + * @param {string} itemId - Item ID + * @returns {Promise} Item details + */ + async fetchItemDetails(itemId) { + try { + if (STATE.slideshow.loadedItems[itemId]) { + return STATE.slideshow.loadedItems[itemId]; + } + + const response = await fetch( + `${STATE.jellyfinData.serverAddress}/Items/${itemId}`, + // `${STATE.jellyfinData.serverAddress}/Users/${STATE.jellyfinData.userId}/Items/${itemId}?Fields=Overview,RemoteTrailers,Genres,CommunityRating,CriticRating,OfficialRating,PremiereDate,RunTimeTicks,ProductionYear,MediaSources`, + { + headers: this.getAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch item details: ${response.statusText}`); + } + + const itemData = await response.json(); + + STATE.slideshow.loadedItems[itemId] = itemData; + + return itemData; + } catch (error) { + console.error(`Error fetching details for item ${itemId}:`, error); + return null; + } + }, + + /** + * Fetch item IDs from the list file + * @returns {Promise} Array of item IDs + */ + // MARK: LIST FILE + async fetchItemIdsFromList() { + try { + const listFileName = `${STATE.jellyfinData.serverAddress}/web/avatars/list.txt?userId=${STATE.jellyfinData.userId}`; + const response = await fetch(listFileName); + + if (!response.ok) { + console.warn("list.txt not found or inaccessible. Using random items."); + return []; + } + + const text = await response.text(); + return text + .split("\n") + .map((id) => id.trim()) + .filter((id) => id) + .slice(1); + } catch (error) { + console.error("Error fetching list.txt:", error); + return []; + } + }, + + /** + * Fetches random items from the server + * @returns {Promise} Array of item objects + */ + async fetchItemIdsFromServer() { + try { + if ( + !STATE.jellyfinData.accessToken || + STATE.jellyfinData.accessToken === "Not Found" + ) { + console.warn("Access token not available. Delaying API request..."); + return []; + } + + if ( + !STATE.jellyfinData.serverAddress || + STATE.jellyfinData.serverAddress === "Not Found" + ) { + console.warn("Server address not available. Delaying API request..."); + return []; + } + + console.log("Fetching random items from server..."); + + const response = await fetch( + `${STATE.jellyfinData.serverAddress}/Items?IncludeItemTypes=Movie,Series&Recursive=true&hasOverview=true&imageTypes=Logo,Backdrop&sortBy=Random&isPlayed=False&enableUserData=true&Limit=${CONFIG.maxItems}&fields=Id`, + { + headers: this.getAuthHeaders(), + } + ); + + if (!response.ok) { + console.error( + `Failed to fetch items: ${response.status} ${response.statusText}` + ); + return []; + } + + const data = await response.json(); + const items = data.Items || []; + + console.log( + `Successfully fetched ${items.length} random items from server` + ); + + return items.map((item) => item.Id); + } catch (error) { + console.error("Error fetching item IDs:", error); + return []; + } + }, + + /** + * Get authentication headers for API requests + * @returns {Object} Headers object + */ + getAuthHeaders() { + return { + Authorization: `MediaBrowser Client="${STATE.jellyfinData.appName}", Device="${STATE.jellyfinData.deviceName}", DeviceId="${STATE.jellyfinData.deviceId}", Version="${STATE.jellyfinData.appVersion}", Token="${STATE.jellyfinData.accessToken}"`, + }; + }, + + /** + * Send a command to play an item + * @param {string} itemId - Item ID to play + * @returns {Promise} Success status + */ + async playItem(itemId) { + try { + const sessionId = await this.getSessionId(); + if (!sessionId) { + console.error("Session ID not found."); + return false; + } + + const playUrl = `${STATE.jellyfinData.serverAddress}/Sessions/${sessionId}/Playing?playCommand=PlayNow&itemIds=${itemId}`; + const playResponse = await fetch(playUrl, { + method: "POST", + headers: this.getAuthHeaders(), + }); + + if (!playResponse.ok) { + throw new Error( + `Failed to send play command: ${playResponse.statusText}` + ); + } + + console.log("Play command sent successfully to session:", sessionId); + return true; + } catch (error) { + console.error("Error sending play command:", error); + return false; + } + }, + + /** + * Gets current session ID + * @returns {Promise} Session ID or null + */ + async getSessionId() { + try { + const response = await fetch( + `${STATE.jellyfinData.serverAddress + }/Sessions?deviceId=${encodeURIComponent(STATE.jellyfinData.deviceId)}`, + { + headers: this.getAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch session data: ${response.statusText}`); + } + + const sessions = await response.json(); + + if (!sessions || sessions.length === 0) { + console.warn( + "No sessions found for deviceId:", + STATE.jellyfinData.deviceId + ); + return null; + } + + return sessions[0].Id; + } catch (error) { + console.error("Error fetching session data:", error); + return null; + } + }, + + //Favorites + + async toggleFavorite(itemId, button) { + try { + const userId = STATE.jellyfinData.userId; + const isFavorite = button.classList.contains("favorited"); + + const url = `${STATE.jellyfinData.serverAddress}/Users/${userId}/FavoriteItems/${itemId}`; + const method = isFavorite ? "DELETE" : "POST"; + + const response = await fetch(url, { + method, + headers: { + ...ApiUtils.getAuthHeaders(), + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to toggle favorite: ${response.statusText}`); + } + button.classList.toggle("favorited", !isFavorite); + } catch (error) { + console.error("Error toggling favorite:", error); + } + }, + + /** + * Fetches SponsorBlock segments for a YouTube video + * @param {string} videoId - YouTube Video ID + * @returns {Promise} Object containing intro and outro segments + */ + async fetchSponsorBlockData(videoId) { + if (!CONFIG.useSponsorBlock) return { intro: null, outro: null }; + try { + const response = await fetch(`https://sponsor.ajay.app/api/skipSegments?videoID=${videoId}&categories=["intro","outro"]`); + if (!response.ok) return { intro: null, outro: null }; + + const segments = await response.json(); + let intro = null; + let outro = null; + + segments.forEach(segment => { + if (segment.category === "intro" && Array.isArray(segment.segment)) { + intro = segment.segment; + } else if (segment.category === "outro" && Array.isArray(segment.segment)) { + outro = segment.segment; + } + }); + + return { intro, outro }; + } catch (error) { + console.warn('Error fetching SponsorBlock data:', error); + return { intro: null, outro: null }; + } + }, + + /** + * Searches for a Collection or Playlist by name + * @param {string} name - Name to search for + * @returns {Promise} ID of the first match or null + */ + async findCollectionOrPlaylistByName(name) { + try { + const response = await fetch( + `${STATE.jellyfinData.serverAddress}/Items?IncludeItemTypes=BoxSet,Playlist&Recursive=true&searchTerm=${encodeURIComponent(name)}&Limit=1&fields=Id&userId=${STATE.jellyfinData.userId}`, + { + headers: this.getAuthHeaders(), + } + ); + + if (!response.ok) { + console.warn(`Failed to search for '${name}'`); + return null; + } + + const data = await response.json(); + if (data.Items && data.Items.length > 0) { + return data.Items[0].Id; + } + return null; + } catch (error) { + console.error(`Error searching for '${name}':`, error); + return null; + } + }, + + /** + * Fetches items belonging to a collection (BoxSet) + * @param {string} collectionId - ID of the collection + * @returns {Promise} Array of item IDs + */ + async fetchCollectionItems(collectionId) { + try { + const response = await fetch( + `${STATE.jellyfinData.serverAddress}/Items?ParentId=${collectionId}&Recursive=true&IncludeItemTypes=Movie,Series&fields=Id&userId=${STATE.jellyfinData.userId}`, + { + headers: this.getAuthHeaders(), + } + ); + + if (!response.ok) { + console.warn(`Failed to fetch collection items for ${collectionId}`); + return []; + } + + const data = await response.json(); + const items = data.Items || []; + console.log(`Resolved collection ${collectionId} to ${items.length} items`); + return items.map(i => i.Id); + } catch (error) { + console.error(`Error fetching collection items for ${collectionId}:`, error); + return []; + } + } +}; + +/** + * Class for managing slide timing + */ +class SlideTimer { + /** + * Creates a new slide timer + * @param {Function} callback - Function to call on interval + * @param {number} interval - Interval in milliseconds + */ + constructor(callback, interval) { + this.callback = callback; + this.interval = interval; + this.timerId = null; + this.start(); + } + + /** + * Stops the timer + * @returns {SlideTimer} This instance for chaining + */ + stop() { + if (this.timerId) { + clearInterval(this.timerId); + this.timerId = null; + } + return this; + } + + /** + * Starts the timer + * @returns {SlideTimer} This instance for chaining + */ + start() { + if (!this.timerId) { + this.timerId = setInterval(this.callback, this.interval); + } + return this; + } + + /** + * Restarts the timer + * @returns {SlideTimer} This instance for chaining + */ + restart() { + return this.stop().start(); + } +} + +/** + * Observer for handling slideshow visibility based on current page + */ +const VisibilityObserver = { + updateVisibility() { + const activeTab = document.querySelector(".emby-tab-button-active"); + const container = document.getElementById("slides-container"); + + if (!container) return; + + const isVisible = + (window.location.hash === "#/home.html" || + window.location.hash === "#/home") && + activeTab.getAttribute("data-index") === "0"; + + container.style.display = isVisible ? "block" : "none"; + + if (isVisible) { + if (STATE.slideshow.slideInterval && !STATE.slideshow.isPaused) { + STATE.slideshow.slideInterval.start(); + } + } else { + if (STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.stop(); + } + } + }, + + /** + * Initializes visibility observer + */ + init() { + const observer = new MutationObserver(this.updateVisibility); + observer.observe(document.body, { childList: true, subtree: true }); + + document.body.addEventListener("click", this.updateVisibility); + window.addEventListener("hashchange", this.updateVisibility); + + this.updateVisibility(); + }, +}; + +/** + * Slideshow UI creation and management + */ +const SlideCreator = { + /** + * Builds a tag-based image URL for cache-friendly image requests + * @param {Object} item - Item data containing ImageTags + * @param {string} imageType - Image type (Backdrop, Logo, Primary, etc.) + * @param {number} [index] - Image index (for Backdrop, Primary, etc.) + * @param {string} serverAddress - Server address + * @param {number} [quality] - Image quality (0-100). If tag is available, both tag and quality are used. + * @returns {string} Image URL with tag parameter (and quality if tag available), or quality-only fallback + */ + buildImageUrl(item, imageType, index, serverAddress, quality) { + const itemId = item.Id; + let tag = null; + + // Handle Backdrop images + if (imageType === "Backdrop") { + // Check BackdropImageTags array first + if (item.BackdropImageTags && Array.isArray(item.BackdropImageTags) && item.BackdropImageTags.length > 0) { + const backdropIndex = index !== undefined ? index : 0; + if (backdropIndex < item.BackdropImageTags.length) { + tag = item.BackdropImageTags[backdropIndex]; + } + } + // Fallback to ImageTags.Backdrop if BackdropImageTags not available + if (!tag && item.ImageTags && item.ImageTags.Backdrop) { + tag = item.ImageTags.Backdrop; + } + } else { + // For other image types (Logo, Primary, etc.), use ImageTags + if (item.ImageTags && item.ImageTags[imageType]) { + tag = item.ImageTags[imageType]; + } + } + + // Build base URL path + let baseUrl; + if (index !== undefined) { + baseUrl = `${serverAddress}/Items/${itemId}/Images/${imageType}/${index}`; + } else { + baseUrl = `${serverAddress}/Items/${itemId}/Images/${imageType}`; + } + + // Build URL with tag and quality if tag is available, otherwise quality-only fallback + if (tag) { + // Use both tag and quality for cacheable, quality-controlled images + const qualityParam = quality !== undefined ? `&quality=${quality}` : ''; + return `${baseUrl}?tag=${tag}${qualityParam}`; + } else { + // Fallback to quality-only URL if no tag is available + const qualityParam = quality !== undefined ? quality : 90; + return `${baseUrl}?quality=${qualityParam}`; + } + }, + + /** + * Creates a slide element for an item + * @param {Object} item - Item data + * @param {string} title - Title type (Movie/TV Show) + * @returns {HTMLElement} Slide element + */ + createSlideElement(item, title) { + if (!item || !item.Id) { + console.error("Invalid item data:", item); + return null; + } + + const itemId = item.Id; + const serverAddress = STATE.jellyfinData.serverAddress; + + const slide = SlideUtils.createElement("a", { + className: "slide", + target: "_top", + rel: "noreferrer", + tabIndex: 0, + "data-item-id": itemId, + }); + + let backdrop; + let isVideo = false; + let trailerUrl = null; + + // 1. Check for Remote Trailers (YouTube) + if (item.RemoteTrailers && item.RemoteTrailers.length > 0) { + trailerUrl = item.RemoteTrailers[0].Url; + } + + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + const shouldPlayVideo = CONFIG.enableVideoBackdrop && (!isMobile || CONFIG.enableMobileVideo); + + if (trailerUrl && shouldPlayVideo) { + let isYoutube = false; + let videoId = null; + + try { + const urlObj = new URL(trailerUrl); + if (urlObj.hostname.includes('youtube.com') || urlObj.hostname.includes('youtu.be')) { + isYoutube = true; + videoId = urlObj.searchParams.get('v'); + if (!videoId && urlObj.hostname.includes('youtu.be')) { + videoId = urlObj.pathname.substring(1); + } + } + } catch (e) { + console.warn("Invalid trailer URL:", trailerUrl); + } + + if (isYoutube && videoId) { + isVideo = true; + // Create container for YouTube API + const videoClass = CONFIG.fullWidthVideo ? "video-backdrop-full" : "video-backdrop-default"; + + backdrop = SlideUtils.createElement("div", { + className: `backdrop video-backdrop ${videoClass}`, + id: `youtube-player-${itemId}` + }); + + // Initialize YouTube Player + SlideUtils.loadYouTubeIframeAPI().then(() => { + // Fetch SponsorBlock data + ApiUtils.fetchSponsorBlockData(videoId).then(segments => { + const playerVars = { + autoplay: 0, + mute: STATE.slideshow.isMuted ? 1 : 0, + controls: 0, + disablekb: 1, + fs: 0, + iv_load_policy: 3, + rel: 0, + loop: 0 + }; + + // Apply SponsorBlock start/end times + if (segments.intro) { + playerVars.start = Math.ceil(segments.intro[1]); + console.info(`SponsorBlock intro detected for video ${videoId}: skipping to ${playerVars.start}s`); + } + if (segments.outro) { + playerVars.end = Math.floor(segments.outro[0]); + console.info(`SponsorBlock outro detected for video ${videoId}: ending at ${playerVars.end}s`); + } + + STATE.slideshow.videoPlayers[itemId] = new YT.Player(`youtube-player-${itemId}`, { + height: '100%', + width: '100%', + videoId: videoId, + playerVars: playerVars, + events: { + 'onReady': (event) => { + // Store start/end time and videoId for later use + event.target._startTime = playerVars.start || 0; + event.target._endTime = playerVars.end || undefined; + event.target._videoId = videoId; + + if (STATE.slideshow.isMuted) { + event.target.mute(); + } else { + event.target.unMute(); + event.target.setVolume(40); + } + + // Only play if this is the active slide + const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); + if (slide && slide.classList.contains('active')) { + event.target.playVideo(); + // Check if it actually started playing after a short delay (handling autoplay blocks) + setTimeout(() => { + if (event.target.getPlayerState() !== YT.PlayerState.PLAYING && + event.target.getPlayerState() !== YT.PlayerState.BUFFERING) { + console.warn(`Autoplay blocked for ${itemId}, attempting muted fallback`); + event.target.mute(); + event.target.playVideo(); + } + }, 1000); + + // Pause slideshow timer when video starts if configured + if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.stop(); + } + } + }, + 'onStateChange': (event) => { + if (event.data === YT.PlayerState.ENDED) { + if (CONFIG.waitForTrailerToEnd) { + SlideshowManager.nextSlide(); + } else { + event.target.playVideo(); // Loop if not waiting for end if trailer is shorter than slide duration + } + } + }, + 'onError': () => { + // Fallback to next slide on error + if (CONFIG.waitForTrailerToEnd) { + SlideshowManager.nextSlide(); + } + } + } + }); + }); + }); + + // 2. Check for local video trailers in MediaSources if yt is not available + } else if (!isYoutube) { + isVideo = true; + + const videoAttributes = { + className: "backdrop video-backdrop", + src: trailerUrl, + autoplay: false, + loop: false, + style: "object-fit: cover; width: 100%; height: 100%; pointer-events: none;" + }; + + if (STATE.slideshow.isMuted) { + videoAttributes.muted = ""; + } + + backdrop = SlideUtils.createElement("video", videoAttributes); + + if (!STATE.slideshow.isMuted) { + backdrop.volume = 0.4; + } + + STATE.slideshow.videoPlayers[itemId] = backdrop; + + backdrop.addEventListener('play', () => { + if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.stop(); + } + }); + + backdrop.addEventListener('ended', () => { + if (CONFIG.waitForTrailerToEnd) { + SlideshowManager.nextSlide(); + } + }); + + backdrop.addEventListener('error', () => { + if (CONFIG.waitForTrailerToEnd) { + SlideshowManager.nextSlide(); + } + }); + } + } + + if (!isVideo) { + backdrop = SlideUtils.createElement("img", { + className: "backdrop high-quality", + src: this.buildImageUrl(item, "Backdrop", 0, serverAddress, 60), + alt: LocalizationUtils.getLocalizedString('Backdrop', 'Backdrop'), + loading: "eager", + }); + } + + const backdropOverlay = SlideUtils.createElement("div", { + className: "backdrop-overlay", + }); + + const backdropContainer = SlideUtils.createElement("div", { + className: "backdrop-container" + (isVideo && CONFIG.fullWidthVideo ? " full-width-video" : ""), + }); + backdropContainer.append(backdrop, backdropOverlay); + + const logo = SlideUtils.createElement("img", { + className: "logo high-quality", + src: this.buildImageUrl(item, "Logo", undefined, serverAddress, 40), + alt: item.Name, + loading: "eager", + }); + + const logoContainer = SlideUtils.createElement("div", { + className: "logo-container", + }); + logoContainer.appendChild(logo); + + const featuredContent = SlideUtils.createElement( + "div", + { + className: "featured-content", + }, + title + ); + + const plot = item.Overview || "No overview available"; + const plotElement = SlideUtils.createElement( + "div", + { + className: "plot", + }, + plot + ); + SlideUtils.truncateText(plotElement, CONFIG.maxPlotLength); + + const plotContainer = SlideUtils.createElement("div", { + className: "plot-container", + }); + plotContainer.appendChild(plotElement); + + const gradientOverlay = SlideUtils.createElement("div", { + className: "gradient-overlay" + (isVideo && CONFIG.fullWidthVideo ? " full-width-video" : ""), + }); + + const infoContainer = SlideUtils.createElement("div", { + className: "info-container", + }); + + const ratingInfo = this.createRatingInfo(item); + infoContainer.appendChild(ratingInfo); + + const genreElement = SlideUtils.createElement("div", { + className: "genre", + innerHTML: SlideUtils.parseGenres(item.Genres) + }); + + const buttonContainer = SlideUtils.createElement("div", { + className: "button-container", + }); + + const playButton = this.createPlayButton(itemId); + const detailButton = this.createDetailButton(itemId); + const favoriteButton = this.createFavoriteButton(item); + + if (trailerUrl && !isVideo && CONFIG.showTrailerButton) { + const trailerButton = this.createTrailerButton(trailerUrl); + buttonContainer.append(detailButton, playButton, trailerButton, favoriteButton); + } else { + buttonContainer.append(detailButton, playButton, favoriteButton); + } + + slide.append( + logoContainer, + backdropContainer, + gradientOverlay, + featuredContent, + plotContainer, + infoContainer, + genreElement, + buttonContainer + ); + + return slide; + }, + + /** + * Creates the rating information element + * @param {Object} item - Item data + * @returns {HTMLElement} Rating information element + */ + createRatingInfo(item) { + const { + CommunityRating: communityRating, + CriticRating: criticRating, + OfficialRating: ageRating, + PremiereDate: premiereDate, + RunTimeTicks: runtime, + ChildCount: seasonCount, + } = item; + + const miscInfo = SlideUtils.createElement("div", { + className: "misc-info", + }); + + // Community Rating Section (IMDb) + if (typeof communityRating === "number") { + const container = SlideUtils.createElement("div", { + className: "star-rating-container", + innerHTML: `${communityRating.toFixed(1)}`, + }); + miscInfo.appendChild(container); + miscInfo.appendChild(SlideUtils.createSeparator()); + } + + // Critic Rating Section (Rotten Tomatoes) + if (typeof criticRating === "number") { + const svgIcon = criticRating < 60 ? CONFIG.IMAGE_SVG.rottenTomato : CONFIG.IMAGE_SVG.freshTomato; + const container = SlideUtils.createElement("div", { + className: "critic-rating", + innerHTML: `${svgIcon}${criticRating.toFixed(0)}%`, + }) + miscInfo.appendChild(container); + miscInfo.appendChild(SlideUtils.createSeparator()); + }; + + // Year Section + if (typeof premiereDate === "string" && !isNaN(new Date(premiereDate))) { + const container = SlideUtils.createElement("div", { + className: "date", + innerHTML: new Date(premiereDate).getFullYear(), + }); + miscInfo.appendChild(container); + miscInfo.appendChild(SlideUtils.createSeparator()); + }; + + // Age Rating Section + if (typeof ageRating === "string") { + const container = SlideUtils.createElement("div", { + className: "age-rating mediaInfoOfficialRating", + rating: ageRating, + ariaLabel: `Content rated ${ageRating}`, + title: `Rating: ${ageRating}`, + innerHTML: ageRating, + }); + miscInfo.appendChild(container); + miscInfo.appendChild(SlideUtils.createSeparator()); + }; + + // Runtime / Seasons Section + if (seasonCount !== undefined || runtime !== undefined) { + const container = SlideUtils.createElement("div", { + className: "runTime", + }); + if (seasonCount) { + const seasonText = seasonCount <= 1 ? LocalizationUtils.getLocalizedString('Season', 'Season') : LocalizationUtils.getLocalizedString('TypeOptionPluralSeason', 'Seasons'); + container.innerHTML = `${seasonCount} ${seasonText}`; + } else { + const milliseconds = runtime / 10000; + const currentTime = new Date(); + const endTime = new Date(currentTime.getTime() + milliseconds); + const options = { hour: "2-digit", minute: "2-digit", hour12: false }; + const formattedEndTime = endTime.toLocaleTimeString([], options); + const endsAtText = LocalizationUtils.getLocalizedString('EndsAtValue', 'Ends at {0}', formattedEndTime); + container.innerText = endsAtText; + } + miscInfo.appendChild(container); + } + + return miscInfo; + }, + + /** + * Creates a play button for an item + * @param {string} itemId - Item ID + * @returns {HTMLElement} Play button element + */ + createPlayButton(itemId) { + const playText = LocalizationUtils.getLocalizedString('Play', 'Play'); + return SlideUtils.createElement("button", { + className: "detailButton btnPlay play-button", + innerHTML: ` + ${playText} + `, + tabIndex: "0", + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + ApiUtils.playItem(itemId); + }, + }); + }, + + /** + * Creates a detail button for an item + * @param {string} itemId - Item ID + * @returns {HTMLElement} Detail button element + */ + createDetailButton(itemId) { + return SlideUtils.createElement("button", { + className: "detailButton detail-button", + tabIndex: "0", + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (window.Emby && window.Emby.Page) { + Emby.Page.show( + `/details?id=${itemId}&serverId=${STATE.jellyfinData.serverId}` + ); + } else { + window.location.href = `#/details?id=${itemId}&serverId=${STATE.jellyfinData.serverId}`; + } + }, + }); + }, + + /** + * Creates a favorite button for an item + * @param {string} itemId - Item ID + * @returns {HTMLElement} Favorite button element + */ + + createFavoriteButton(item) { + const isFavorite = item.UserData && item.UserData.IsFavorite === true; + + const button = SlideUtils.createElement("button", { + className: `favorite-button ${isFavorite ? "favorited" : ""}`, + tabIndex: "0", + onclick: async (e) => { + e.preventDefault(); + e.stopPropagation(); + await ApiUtils.toggleFavorite(item.Id, button); + }, + }); + + return button; + }, + + /** + * Creates a trailer button + * @param {string} url - Trailer URL + * @returns {HTMLElement} Trailer button element + */ + createTrailerButton(url) { + const trailerText = LocalizationUtils.getLocalizedString('Trailer', 'Trailer'); + return SlideUtils.createElement("button", { + className: "detailButton trailer-button", + innerHTML: `movie ${trailerText}`, + tabIndex: "0", + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + SlideUtils.openVideoModal(url); + }, + }); + }, + + + /** + * Creates a placeholder slide for loading + * @param {string} itemId - Item ID to load + * @returns {HTMLElement} Placeholder slide element + */ + createLoadingPlaceholder(itemId) { + const placeholder = SlideUtils.createElement("a", { + className: "slide placeholder", + "data-item-id": itemId, + style: { + display: "none", + opacity: "0", + transition: `opacity ${CONFIG.fadeTransitionDuration}ms ease-in-out`, + }, + }); + + const loadingIndicator = SlideUtils.createLoadingIndicator(); + placeholder.appendChild(loadingIndicator); + + return placeholder; + }, + + /** + * Creates a slide for an item and adds it to the container + * @param {string} itemId - Item ID + * @returns {Promise} Created slide element + */ + async createSlideForItemId(itemId) { + try { + if (STATE.slideshow.createdSlides[itemId]) { + return document.querySelector(`.slide[data-item-id="${itemId}"]`); + } + + const container = SlideUtils.getOrCreateSlidesContainer(); + + const item = await ApiUtils.fetchItemDetails(itemId); + + const slideElement = this.createSlideElement( + item, + item.Type === "Movie" ? "Movie" : "TV Show" + ); + + container.appendChild(slideElement); + + STATE.slideshow.createdSlides[itemId] = true; + + return slideElement; + } catch (error) { + console.error("Error creating slide for item:", error, itemId); + return null; + } + }, +}; + +/** + * Manages slideshow functionality + */ +const SlideshowManager = { + + createPaginationDots() { + let dotsContainer = document.querySelector(".dots-container"); + if (!dotsContainer) { + dotsContainer = document.createElement("div"); + dotsContainer.className = "dots-container"; + document.getElementById("slides-container").appendChild(dotsContainer); + } + + const totalItems = STATE.slideshow.totalItems || 0; + + // Switch to counter style if too many items + if (totalItems > CONFIG.maxPaginationDots) { + const counter = document.createElement("span"); + counter.className = "slide-counter"; + counter.id = "slide-counter"; + dotsContainer.appendChild(counter); + } else { + // Create dots for all items + for (let i = 0; i < totalItems; i++) { + const dot = document.createElement("span"); + dot.className = "dot"; + dot.setAttribute("data-index", i); + dotsContainer.appendChild(dot); + } + } + + this.updateDots(); + }, + + /** + * Updates active dot based on current slide + * Maps current slide to one of the 5 dots + */ + updateDots() { + const currentIndex = STATE.slideshow.currentSlideIndex; + const totalItems = STATE.slideshow.totalItems || 0; + + // Handle Large List Counter + const counter = document.getElementById("slide-counter"); + if (counter) { + counter.textContent = `${currentIndex + 1} / ${totalItems}`; + return; + } + + // Handle Dots + const container = SlideUtils.getOrCreateSlidesContainer(); + const dots = container.querySelectorAll(".dot"); + + // Fallback if dots exist but totalItems matched counter mode + if (dots.length === 0) return; + + dots.forEach((dot, index) => { + if (index === currentIndex) { + dot.classList.add("active"); + } else { + dot.classList.remove("active"); + } + }); + }, + + /** + * Updates current slide to the specified index + * @param {number} index - Slide index to display + */ + + async updateCurrentSlide(index) { + if (STATE.slideshow.isTransitioning) { + return; + } + + STATE.slideshow.isTransitioning = true; + + let previousVisibleSlide; + try { + const container = SlideUtils.getOrCreateSlidesContainer(); + const totalItems = STATE.slideshow.totalItems; + + index = Math.max(0, Math.min(index, totalItems - 1)); + const currentItemId = STATE.slideshow.itemIds[index]; + + let currentSlide = document.querySelector( + `.slide[data-item-id="${currentItemId}"]` + ); + if (!currentSlide) { + currentSlide = await SlideCreator.createSlideForItemId(currentItemId); + this.upgradeSlideImageQuality(currentSlide); + + if (!currentSlide) { + console.error(`Failed to create slide for item ${currentItemId}`); + STATE.slideshow.isTransitioning = false; + setTimeout(() => this.nextSlide(), 500); + return; + } + } + + previousVisibleSlide = container.querySelector(".slide.active"); + + if (previousVisibleSlide) { + previousVisibleSlide.classList.remove("active"); + } + + currentSlide.classList.add("active"); + + // Manage Video Playback: Stop others, Play current + + // 1. Pause all other YouTube players + if (STATE.slideshow.videoPlayers) { + Object.keys(STATE.slideshow.videoPlayers).forEach(id => { + if (id !== currentItemId) { + const p = STATE.slideshow.videoPlayers[id]; + if (p && typeof p.pauseVideo === 'function') { + p.pauseVideo(); + } + } + }); + } + + // 2. Pause all other HTML5 videos e.g. local trailers + document.querySelectorAll('video').forEach(video => { + if (!video.closest(`.slide[data-item-id="${currentItemId}"]`)) { + video.pause(); + } + }); + + // 3. Play and Reset current video + const videoBackdrop = currentSlide.querySelector('.video-backdrop'); + + // Update mute button visibility + const muteButton = document.querySelector('.mute-button'); + if (muteButton) { + const hasVideo = !!videoBackdrop; + muteButton.style.display = hasVideo ? 'block' : 'none'; + } + + if (videoBackdrop) { + if (videoBackdrop.tagName === 'VIDEO') { + videoBackdrop.currentTime = 0; + + videoBackdrop.muted = STATE.slideshow.isMuted; + if (!STATE.slideshow.isMuted) { + videoBackdrop.volume = 0.4; + } + + videoBackdrop.play().catch(e => { + // Check if it actually started playing after a short delay (handling autoplay blocks) + setTimeout(() => { + if (videoBackdrop.paused) { + console.warn(`Autoplay blocked for ${itemId}, attempting muted fallback`); + videoBackdrop.muted = true; + videoBackdrop.play().catch(err => console.error("Muted fallback failed", err)); + } + }, 1000); + }); + } else if (STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]) { + const player = STATE.slideshow.videoPlayers[currentItemId]; + if (player && typeof player.loadVideoById === 'function' && player._videoId) { + // Use loadVideoById to enforce start and end times + player.loadVideoById({ + videoId: player._videoId, + startSeconds: player._startTime || 0, + endSeconds: player._endTime + }); + + if (STATE.slideshow.isMuted) { + player.mute(); + } else { + player.unMute(); + player.setVolume(40); + } + + // Check if playback successfully started, otherwise fallback to muted + setTimeout(() => { + if (player.getPlayerState && + player.getPlayerState() !== YT.PlayerState.PLAYING && + player.getPlayerState() !== YT.PlayerState.BUFFERING) { + console.log("YouTube loadVideoById didn't start playback, retrying muted..."); + player.mute(); + player.playVideo(); + } + }, 1000); + } else if (player && typeof player.seekTo === 'function') { + // Fallback if loadVideoById is not available or videoId missing + const startTime = player._startTime || 0; + player.seekTo(startTime); + player.playVideo(); + } + } + } + + if (CONFIG.slideAnimationEnabled) { + const backdrop = currentSlide.querySelector(".backdrop"); + if (backdrop && !backdrop.classList.contains("video-backdrop")) { + backdrop.classList.add("animate"); + } + currentSlide.querySelector(".logo").classList.add("animate"); + } + + STATE.slideshow.currentSlideIndex = index; + + if (index === 0 || !previousVisibleSlide) { + const dotsContainer = container.querySelector(".dots-container"); + if (dotsContainer) { + dotsContainer.style.opacity = "1"; + } + } + + setTimeout(() => { + const allSlides = container.querySelectorAll(".slide"); + allSlides.forEach((slide) => { + if (slide !== currentSlide) { + slide.classList.remove("active"); + } + }); + }, CONFIG.fadeTransitionDuration); + + this.preloadAdjacentSlides(index); + this.updateDots(); + + // Only restart interval if we are NOT waiting for a video to end + const hasVideo = currentSlide.querySelector('.video-backdrop'); + if (STATE.slideshow.slideInterval && !STATE.slideshow.isPaused) { + if (CONFIG.waitForTrailerToEnd && hasVideo) { + STATE.slideshow.slideInterval.stop(); + } else { + STATE.slideshow.slideInterval.restart(); + } + } + + this.pruneSlideCache(); + } catch (error) { + console.error("Error updating current slide:", error); + } finally { + setTimeout(() => { + STATE.slideshow.isTransitioning = false; + + if (previousVisibleSlide && CONFIG.slideAnimationEnabled) { + const prevBackdrop = previousVisibleSlide.querySelector(".backdrop"); + const prevLogo = previousVisibleSlide.querySelector(".logo"); + if (prevBackdrop) prevBackdrop.classList.remove("animate"); + if (prevLogo) prevLogo.classList.remove("animate"); + } + }, CONFIG.fadeTransitionDuration); + } + }, + + /** + * Upgrades the image quality for all images in a slide + * @param {HTMLElement} slide - The slide element containing images to upgrade + */ + + upgradeSlideImageQuality(slide) { + if (!slide) return; + + const images = slide.querySelectorAll("img.low-quality"); + images.forEach((img) => { + const highQualityUrl = img.getAttribute("data-high-quality"); + + // Prevent duplicate requests if already using high quality + if (highQualityUrl && img.src !== highQualityUrl) { + addThrottledRequest(highQualityUrl, () => { + img.src = highQualityUrl; + img.classList.remove("low-quality"); + img.classList.add("high-quality"); + }); + } + }); + }, + + /** + * Preloads adjacent slides for smoother transitions + * @param {number} currentIndex - Current slide index + */ + async preloadAdjacentSlides(currentIndex) { + const totalItems = STATE.slideshow.totalItems; + const preloadCount = CONFIG.preloadCount; + + const nextIndex = (currentIndex + 1) % totalItems; + const itemId = STATE.slideshow.itemIds[nextIndex]; + + await SlideCreator.createSlideForItemId(itemId); + + if (preloadCount > 1) { + const prevIndex = (currentIndex - 1 + totalItems) % totalItems; + const prevItemId = STATE.slideshow.itemIds[prevIndex]; + + SlideCreator.createSlideForItemId(prevItemId); + } + }, + + nextSlide() { + const currentIndex = STATE.slideshow.currentSlideIndex; + const totalItems = STATE.slideshow.totalItems; + + const nextIndex = (currentIndex + 1) % totalItems; + + this.updateCurrentSlide(nextIndex); + }, + + prevSlide() { + const currentIndex = STATE.slideshow.currentSlideIndex; + const totalItems = STATE.slideshow.totalItems; + + const prevIndex = (currentIndex - 1 + totalItems) % totalItems; + + this.updateCurrentSlide(prevIndex); + }, + + /** + * Prunes the slide cache to prevent memory bloat + * Removes slides that are outside the viewing range + */ + pruneSlideCache() { + const currentIndex = STATE.slideshow.currentSlideIndex; + const keepRange = 5; + + Object.keys(STATE.slideshow.createdSlides).forEach((itemId) => { + const index = STATE.slideshow.itemIds.indexOf(itemId); + if (index === -1) return; + + const distance = Math.abs(index - currentIndex); + if (distance > keepRange) { + // Destroy video player if exists + if (STATE.slideshow.videoPlayers[itemId]) { + const player = STATE.slideshow.videoPlayers[itemId]; + if (typeof player.destroy === 'function') { + player.destroy(); + } + delete STATE.slideshow.videoPlayers[itemId]; + } + + delete STATE.slideshow.loadedItems[itemId]; + + const slide = document.querySelector( + `.slide[data-item-id="${itemId}"]` + ); + if (slide) slide.remove(); + + delete STATE.slideshow.createdSlides[itemId]; + + console.log(`Pruned slide ${itemId} at distance ${distance} from view`); + } + }); + }, + + toggleMute() { + STATE.slideshow.isMuted = !STATE.slideshow.isMuted; + const isUnmuting = !STATE.slideshow.isMuted; + const muteButton = document.querySelector('.mute-button'); + + const updateIcon = () => { + if (!muteButton) return; + const isMuted = STATE.slideshow.isMuted; + muteButton.innerHTML = `${isMuted ? 'volume_off' : 'volume_up'}`; + const label = isMuted ? 'Unmute' : 'Mute'; + muteButton.setAttribute("aria-label", LocalizationUtils.getLocalizedString(label, label)); + muteButton.setAttribute("title", LocalizationUtils.getLocalizedString(label, label)); + }; + + const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex]; + const player = STATE.slideshow.videoPlayers ? STATE.slideshow.videoPlayers[currentItemId] : null; + + if (currentItemId) { + const currentSlide = document.querySelector(`.slide[data-item-id="${currentItemId}"]`); + const video = currentSlide?.querySelector('video'); + + if (video) { + video.muted = STATE.slideshow.isMuted; + if (!STATE.slideshow.isMuted) { + video.volume = 0.4; + } + + video.play().catch(error => { + console.warn("Unmuted play blocked, reverting to muted..."); + STATE.slideshow.isMuted = true; + video.muted = true; + video.play(); + updateIcon(); + }); + } + + if (player && typeof player.playVideo === 'function') { + if (STATE.slideshow.isMuted) { + player.mute(); + } else { + player.unMute(); + player.setVolume(40); + } + + player.playVideo(); + if (isUnmuting) { + setTimeout(() => { + const state = player.getPlayerState(); + if (state === 2) { + console.log("Video was paused after unmute..."); + STATE.slideshow.isMuted = true; + player.mute(); + player.playVideo(); + updateIcon(); + } + }, 300); + } + } + } + + updateIcon(); + }, + + togglePause() { + STATE.slideshow.isPaused = !STATE.slideshow.isPaused; + const pauseButton = document.querySelector('.pause-button'); + + // Handle current video playback + const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex]; + const currentSlide = document.querySelector(`.slide[data-item-id="${currentItemId}"]`); + + if (currentSlide) { + // Try YouTube player + const ytPlayer = STATE.slideshow.videoPlayers[currentItemId]; + if (ytPlayer && typeof ytPlayer.getPlayerState === 'function') { + if (STATE.slideshow.isPaused) { + ytPlayer.pauseVideo(); + } else { + ytPlayer.playVideo(); + } + } + + // Try HTML5 video + const html5Video = currentSlide.querySelector('video'); + if (html5Video) { + if (STATE.slideshow.isPaused) { + html5Video.pause(); + } else { + html5Video.play(); + } + } + } + + if (STATE.slideshow.isPaused) { + STATE.slideshow.slideInterval.stop(); + pauseButton.innerHTML = 'play_arrow'; + const playLabel = LocalizationUtils.getLocalizedString('Play', 'Play'); + pauseButton.setAttribute("aria-label", playLabel); + pauseButton.setAttribute("title", playLabel); + } else { + // Only restart interval if we are NOT waiting for a video to end + const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex]; + const currentSlide = document.querySelector(`.slide[data-item-id="${currentItemId}"]`); + const hasVideo = currentSlide && currentSlide.querySelector('.video-backdrop'); + + if (!CONFIG.waitForTrailerToEnd || !hasVideo) { + STATE.slideshow.slideInterval.start(); + } + + pauseButton.innerHTML = 'pause'; + const pauseLabel = LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'); + pauseButton.setAttribute("aria-label", pauseLabel); + pauseButton.setAttribute("title", pauseLabel); + } + }, + + /** + * Initializes touch events for swiping + */ + initTouchEvents() { + const container = SlideUtils.getOrCreateSlidesContainer(); + let touchStartX = 0; + let touchEndX = 0; + + container.addEventListener( + "touchstart", + (e) => { + touchStartX = e.changedTouches[0].screenX; + }, + { passive: true } + ); + + container.addEventListener( + "touchend", + (e) => { + touchEndX = e.changedTouches[0].screenX; + this.handleSwipe(touchStartX, touchEndX); + }, + { passive: true } + ); + }, + + /** + * Handles swipe gestures + * @param {number} startX - Starting X position + * @param {number} endX - Ending X position + */ + handleSwipe(startX, endX) { + const diff = endX - startX; + + if (Math.abs(diff) < CONFIG.minSwipeDistance) { + return; + } + + if (diff > 0) { + this.prevSlide(); + } else { + this.nextSlide(); + } + }, + + /** + * Initializes keyboard event listeners + */ + initKeyboardEvents() { + if (!CONFIG.enableKeyboardControls) return; + + document.addEventListener("keydown", (e) => { + const container = document.getElementById("slides-container"); + // Allow interaction if container is visible, even if not strictly focused + if (!container || container.style.display === "none") { + return; + } + + const focusElement = document.activeElement; + + switch (e.key) { + case "ArrowRight": + if (focusElement && focusElement.classList.contains("detail-button")) { + focusElement.previousElementSibling.focus(); + } else { + SlideshowManager.nextSlide(); + } + e.preventDefault(); + break; + + case "ArrowLeft": + if (focusElement && focusElement.classList.contains("play-button")) { + focusElement.nextElementSibling.focus(); + } else { + SlideshowManager.prevSlide(); + } + e.preventDefault(); + break; + + case " ": // Space bar + this.togglePause(); + e.preventDefault(); + break; + + case "m": // Mute toggle + case "M": + this.toggleMute(); + e.preventDefault(); + break; + + case "Enter": + if (focusElement) { + focusElement.click(); + } + e.preventDefault(); + break; + } + }); + + const container = SlideUtils.getOrCreateSlidesContainer(); + + container.addEventListener("focus", () => { + STATE.slideshow.containerFocused = true; + }); + + container.addEventListener("blur", () => { + STATE.slideshow.containerFocused = false; + }); + }, + + /** + * Parses custom media IDs, handling seasonal content if enabled + * @returns {string[]} Array of media IDs + */ + parseCustomIds() { + if (!CONFIG.enableSeasonalContent) { + return CONFIG.customMediaIds + .split(/[\n,]/) // Split by comma or newline + .map((id) => id.trim()) // Remove whitespace + .filter((id) => id); // Remove empty strings + } else { + return this.parseSeasonalIds(); + } + }, + + /** + * Parses custom media IDs, handling seasonal content if enabled + * @returns {string[]} Array of media IDs + */ + parseSeasonalIds() { + console.log("Using Seasonal Content Mode"); + const lines = CONFIG.customMediaIds.split('\n'); + const currentDate = new Date(); + const currentMonth = currentDate.getMonth() + 1; // 1-12 + const currentDay = currentDate.getDate(); // 1-31 + const rawIds = []; + + for (const line of lines) { + const match = line.match(/^\s*(\d{1,2})\.(\d{1,2})-(\d{1,2})\.(\d{1,2})\s*\|.*\|(.*)$/) || + line.match(/^\s*(\d{1,2})\.(\d{1,2})-(\d{1,2})\.(\d{1,2})\s*\|(.*)$/); + + if (match) { + const startDay = parseInt(match[1]); + const startMonth = parseInt(match[2]); + const endDay = parseInt(match[3]); + const endMonth = parseInt(match[4]); + const idsPart = match[5]; + + let isInRange = false; + + if (startMonth === endMonth) { + if (currentMonth === startMonth && currentDay >= startDay && currentDay <= endDay) { + isInRange = true; + } + } else if (startMonth < endMonth) { + // Normal range spanning months (e.g. 15.06 - 15.08) + if ( + (currentMonth > startMonth && currentMonth < endMonth) || + (currentMonth === startMonth && currentDay >= startDay) || + (currentMonth === endMonth && currentDay <= endDay) + ) { + isInRange = true; + } + } else { + // Wrap around year (e.g. 01.12 - 15.01) + if ( + (currentMonth > startMonth || currentMonth < endMonth) || + (currentMonth === startMonth && currentDay >= startDay) || + (currentMonth === endMonth && currentDay <= endDay) + ) { + isInRange = true; + } + } + + if (isInRange) { + console.log(`Seasonal match found: ${line}`); + const ids = idsPart.split(/[,]/).map(id => id.trim()).filter(id => id); + rawIds.push(...ids); + } + } + } + return rawIds; + }, + + /** + * Resolves a list of IDs, expanding collections (BoxSets) into their children + * @param {string[]} rawIds - List of input IDs + * @returns {Promise} Flattened list of item IDs + */ + async resolveCollectionsAndItems(rawIds) { + const finalIds = []; + const guidRegex = /^([0-9a-f]{32})$/i; + + for (const rawId of rawIds) { + try { + let id = rawId; + + // If not a valid GUID, treat as a name and search + if (!guidRegex.test(rawId)) { + console.log(`Input '${rawId}' is not a GUID, searching for Collection/Playlist by name...`); + const resolvedId = await ApiUtils.findCollectionOrPlaylistByName(rawId); + + if (resolvedId) { + console.log(`Resolved name '${rawId}' to ID: ${resolvedId}`); + id = resolvedId; + } else { + console.warn(`Could not find Collection or Playlist with name: '${rawId}'`); + continue; // Skip if resolution failed + } + } + + const item = await ApiUtils.fetchItemDetails(id); + if (item && (item.Type === 'BoxSet' || item.Type === 'Playlist')) { + console.log(`Found Collection/Playlist: ${id} (${item.Type}), fetching children...`); + const children = await ApiUtils.fetchCollectionItems(id); + finalIds.push(...children); + } else if (item) { + finalIds.push(id); + } + } catch (e) { + console.warn(`Error resolving item ${id}:`, e); + } + } + return finalIds; + }, + + /** + * Loads slideshow data and initializes the slideshow + */ + async loadSlideshowData() { + try { + STATE.slideshow.isLoading = true; + let itemIds = []; + + // 1. Try Custom Media/Collection IDs from Config & seasonal content + if (CONFIG.enableCustomMediaIds && CONFIG.customMediaIds) { + console.log("Using Custom Media IDs from configuration"); + const rawIds = this.parseCustomIds(); + itemIds = await this.resolveCollectionsAndItems(rawIds); + } + + // 2. Try Avatar List (list.txt) + if (itemIds.length === 0) { + itemIds = await ApiUtils.fetchItemIdsFromList(); + } + + // 3. Fallback to server query (Random) + if (itemIds.length === 0) { + console.log("No custom list found, fetching random items from server..."); + itemIds = await ApiUtils.fetchItemIdsFromServer(); + } + + itemIds = SlideUtils.shuffleArray(itemIds); + + STATE.slideshow.itemIds = itemIds; + STATE.slideshow.totalItems = itemIds.length; + + this.createPaginationDots(); + + await this.updateCurrentSlide(0); + + STATE.slideshow.slideInterval = new SlideTimer(() => { + if (STATE.slideshow.isPaused) return; + + if (CONFIG.waitForTrailerToEnd) { + const activeSlide = document.querySelector('.slide.active'); + const hasActiveVideo = !!(activeSlide && activeSlide.querySelector('.video-backdrop')); + if (hasActiveVideo) return; + } + + this.nextSlide(); + }, CONFIG.shuffleInterval); + + if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) { + const activeSlide = document.querySelector('.slide.active'); + const hasActiveVideo = !!(activeSlide && activeSlide.querySelector('.video-backdrop')); + if (hasActiveVideo) { + STATE.slideshow.slideInterval.stop(); + } + } + } catch (error) { + console.error("Error loading slideshow data:", error); + } finally { + STATE.slideshow.isLoading = false; + } + }, +}; + +/** + * Initializes arrow navigation elements + */ +const initArrowNavigation = () => { + const container = SlideUtils.getOrCreateSlidesContainer(); + + const leftArrow = SlideUtils.createElement("div", { + className: "arrow left-arrow", + innerHTML: 'chevron_left', + tabIndex: "0", + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + SlideshowManager.prevSlide(); + }, + style: { + opacity: "0", + transition: "opacity 0.3s ease", + display: "none", + }, + }); + + const rightArrow = SlideUtils.createElement("div", { + className: "arrow right-arrow", + innerHTML: 'chevron_right', + tabIndex: "0", + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + SlideshowManager.nextSlide(); + }, + style: { + opacity: "0", + transition: "opacity 0.3s ease", + display: "none", + }, + }); + + const pauseButton = SlideUtils.createElement("div", { + className: "pause-button", + innerHTML: 'pause', + tabIndex: "0", + "aria-label": LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'), + title: LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'), + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + SlideshowManager.togglePause(); + } + }); + + // Prevent touch events from bubbling to container + pauseButton.addEventListener("touchstart", (e) => e.stopPropagation(), { passive: true }); + pauseButton.addEventListener("touchend", (e) => e.stopPropagation(), { passive: true }); + pauseButton.addEventListener("mousedown", (e) => e.stopPropagation()); + + const muteButton = SlideUtils.createElement("div", { + className: "mute-button", + innerHTML: STATE.slideshow.isMuted ? 'volume_off' : 'volume_up', + tabIndex: "0", + "aria-label": STATE.slideshow.isMuted ? LocalizationUtils.getLocalizedString('Unmute', 'Unmute') : LocalizationUtils.getLocalizedString('Mute', 'Mute'), + title: STATE.slideshow.isMuted ? LocalizationUtils.getLocalizedString('Unmute', 'Unmute') : LocalizationUtils.getLocalizedString('Mute', 'Mute'), + style: { display: "none" }, + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + SlideshowManager.toggleMute(); + } + }); + + // Prevent touch events from bubbling to container + muteButton.addEventListener("touchstart", (e) => e.stopPropagation(), { passive: true }); + muteButton.addEventListener("touchend", (e) => e.stopPropagation(), { passive: true }); + muteButton.addEventListener("mousedown", (e) => e.stopPropagation()); + + container.appendChild(leftArrow); + container.appendChild(rightArrow); + container.appendChild(pauseButton); + container.appendChild(muteButton); + + const showArrows = () => { + leftArrow.style.display = "block"; + rightArrow.style.display = "block"; + + void leftArrow.offsetWidth; + void rightArrow.offsetWidth; + + leftArrow.style.opacity = "1"; + rightArrow.style.opacity = "1"; + }; + + const hideArrows = () => { + leftArrow.style.opacity = "0"; + rightArrow.style.opacity = "0"; + + setTimeout(() => { + if (leftArrow.style.opacity === "0") { + leftArrow.style.display = "none"; + rightArrow.style.display = "none"; + } + }, 300); + }; + + container.addEventListener("mouseenter", showArrows); + + container.addEventListener("mouseleave", hideArrows); + + if (CONFIG.alwaysShowArrows) { + showArrows(); + // Remove listeners to keep them shown + container.removeEventListener("mouseenter", showArrows); + container.removeEventListener("mouseleave", hideArrows); + } + + let arrowTimeout; + container.addEventListener( + "touchstart", + () => { + if (arrowTimeout) { + clearTimeout(arrowTimeout); + } + + showArrows(); + + arrowTimeout = setTimeout(hideArrows, 2000); + }, + { passive: true } + ); +}; + +/** + * Initialize the slideshow + */ +const slidesInit = async () => { + if (STATE.slideshow.hasInitialized) { + console.log("⚠️ Slideshow already initialized, skipping"); + return; + } + STATE.slideshow.hasInitialized = true; + + /** + * Initialize IntersectionObserver for lazy loading images + */ + const initLazyLoading = () => { + const imageObserver = new IntersectionObserver( + (entries, observer) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const image = entry.target; + const highQualityUrl = image.getAttribute("data-high-quality"); + + if ( + highQualityUrl && + image.closest(".slide").style.opacity === "1" + ) { + requestQueue.push({ + url: highQualityUrl, + callback: () => { + image.src = highQualityUrl; + image.classList.remove("low-quality"); + image.classList.add("high-quality"); + }, + }); + + if (requestQueue.length === 1) { + processNextRequest(); + } + } + + observer.unobserve(image); + } + }); + }, + { + rootMargin: "50px", + threshold: 0.1, + } + ); + + const observeSlideImages = () => { + const slides = document.querySelectorAll(".slide"); + slides.forEach((slide) => { + const images = slide.querySelectorAll("img.low-quality"); + images.forEach((image) => { + imageObserver.observe(image); + }); + }); + }; + + const slideObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.addedNodes) { + mutation.addedNodes.forEach((node) => { + if (node.classList && node.classList.contains("slide")) { + const images = node.querySelectorAll("img.low-quality"); + images.forEach((image) => { + imageObserver.observe(image); + }); + } + }); + } + }); + }); + + const container = SlideUtils.getOrCreateSlidesContainer(); + slideObserver.observe(container, { childList: true }); + + observeSlideImages(); + + return imageObserver; + }; + + const lazyLoadObserver = initLazyLoading(); + + try { + console.log("🌟 Initializing Enhanced Jellyfin Slideshow"); + + initArrowNavigation(); + + await SlideshowManager.loadSlideshowData(); + + SlideshowManager.initTouchEvents(); + + SlideshowManager.initKeyboardEvents(); + + VisibilityObserver.init(); + + console.log("✅ Enhanced Jellyfin Slideshow initialized successfully"); + } catch (error) { + console.error("Error initializing slideshow:", error); + STATE.slideshow.hasInitialized = false; + } +}; + +window.slideshowPure = { + CONFIG, + STATE, + SlideUtils, + ApiUtils, + SlideCreator, + SlideshowManager, + VisibilityObserver, + initSlideshowData: () => { + SlideshowManager.loadSlideshowData(); + }, + nextSlide: () => { + SlideshowManager.nextSlide(); + }, + prevSlide: () => { + SlideshowManager.prevSlide(); + }, +}; + +initLoadingScreen(); + +loadPluginConfig().then(() => { + startLoginStatusWatcher(); +}); 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/jellyfin.ruleset b/jellyfin.ruleset new file mode 100644 index 0000000..8af791c --- /dev/null +++ b/jellyfin.ruleset @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..fd6d433 Binary files /dev/null and b/logo.png differ diff --git a/logos/MediaBar_logo.png b/logos/MediaBar_logo.png new file mode 100644 index 0000000..fd6d433 Binary files /dev/null and b/logos/MediaBar_logo.png differ diff --git a/logos/MediaBar_logo_heller.png b/logos/MediaBar_logo_heller.png new file mode 100644 index 0000000..e062737 Binary files /dev/null and b/logos/MediaBar_logo_heller.png differ diff --git a/logos/MediaBar_logo_old.png b/logos/MediaBar_logo_old.png new file mode 100644 index 0000000..bf6cc68 Binary files /dev/null and b/logos/MediaBar_logo_old.png differ diff --git a/logos/MediaBar_logo_stacked.png b/logos/MediaBar_logo_stacked.png new file mode 100644 index 0000000..404b46a Binary files /dev/null and b/logos/MediaBar_logo_stacked.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0ec4ac9 --- /dev/null +++ b/manifest.json @@ -0,0 +1,173 @@ +[ + { + "guid": "d7e11d57-819b-4bdd-a88d-53c5f5560225", + "name": "Media Bar Enhanced", + "description": "Adds a enhanced media bar (featured content) to the Jellyfin web interface.", + "overview": "Media Bar for Jellyfin", + "owner": "CodeDevMLH", + "category": "General", + "imageUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/raw/branch/main/logo.png", + "versions": [ + { + "version": "1.5.0.0", + "changelog": "Renamed to Media Bar Enhanced", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.5.0.0/Jellyfin.Plugin.MediaBarEnhanced.zip", + "checksum": "5a7793f2aa64549c5bd8afecb482ceb6", + "timestamp": "2026-01-06T01:01:25Z" + }, + { + "version": "1.4.0.0", + "changelog": "Add more config description, add search collection/playlist by name", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.4.0.0/Jellyfin.Plugin.MediaBar.zip", + "checksum": "b8a3667fa8290242c74411b2bd1c06da", + "timestamp": "2026-01-04T23:50:01Z" + }, + { + "version": "1.3.0.0", + "changelog": "Add file transformation fallback", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.3.0.0/Jellyfin.Plugin.MediaBar.zip", + "checksum": "4379eab39684501af97876abe1b4ab91", + "timestamp": "2026-01-04T23:47:38Z" + }, + { + "version": "1.2.0.0", + "changelog": "Add seasonals content mode", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.2.0.0/Jellyfin.Plugin.MediaBar.zip", + "checksum": "8ad0b9d38aa4bd4bd16e1f9f0e6c4d8c", + "timestamp": "2026-01-04T15:13:36Z" + }, + { + "version": "1.1.0.6", + "changelog": "UI improvements", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.1.0.6/Jellyfin.Plugin.MediaBar.zip", + "checksum": "91d342865ebdf9b4efd53561125c5604", + "timestamp": "2026-01-04T14:19:50Z" + }, + { + "version": "1.1.0.5", + "changelog": "Added collection (boxsets) IDs to slideshow option", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.1.0.5/Jellyfin.Plugin.MediaBar.zip", + "checksum": "41b4ddf3b6f9fc79eac64acb24989e67", + "timestamp": "2026-01-04T14:09:29Z" + }, + { + "version": "1.1.0.4", + "changelog": "Added loading screen disable option T3", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.1.0.4/Jellyfin.Plugin.MediaBar.zip", + "checksum": "f0d2bf6c1ce7bd7166776cd7a4e59d6f", + "timestamp": "2026-01-04T12:41:07Z" + }, + { + "version": "1.1.0.3", + "changelog": "Added loading screen disable option", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.1.0.3/Jellyfin.Plugin.MediaBar.zip", + "checksum": "f93373b9bb1f3614e4d4237b04619c32", + "timestamp": "2026-01-04T01:57:20Z" + }, + { + "version": "1.1.0.2", + "changelog": "Added loading screen disable option", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.1.0.2/Jellyfin.Plugin.MediaBar.zip", + "checksum": "06907adcd3f6e022c622d5c91119d1e1", + "timestamp": "2026-01-03T23:03:51Z" + }, + { + "version": "1.1.0.1", + "changelog": "Added custom media ids", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.1.0.1/Jellyfin.Plugin.MediaBar.zip", + "checksum": "81e90c7a8778856641bbc47ac60ebb32", + "timestamp": "2026-01-03T23:00:39Z" + }, + { + "version": "1.1.0.0", + "changelog": "Added custom media ids", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.1.0.0/Jellyfin.Plugin.MediaBar.zip", + "checksum": "03ee3f2eed26ebbc959ef49730018a98", + "timestamp": "2026-01-03T22:44:54Z" + }, + { + "version": "1.0.0.8", + "changelog": "small ui changes", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.0.0.8/Jellyfin.Plugin.MediaBar.zip", + "checksum": "f1e8acb78422f6dc729b4086deb9929e", + "timestamp": "2026-01-03T18:43:37Z" + }, + { + "version": "1.0.0.7", + "changelog": "always show arrows & keyboard controls", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.0.0.7/Jellyfin.Plugin.MediaBar.zip", + "checksum": "e88519a9a7405eb5083e7c950eb4a08b", + "timestamp": "2026-01-03T18:22:25Z" + }, + { + "version": "1.0.0.6", + "changelog": "", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.0.0.6/Jellyfin.Plugin.MediaBar.zip", + "checksum": "18137a92aa8966584e123ba76bbe71c2", + "timestamp": "2026-01-03T18:11:43Z" + }, + { + "version": "1.0.0.5", + "changelog": "", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.0.0.5/Jellyfin.Plugin.MediaBar.zip", + "checksum": "156f980aad077b272887a6ab3cfcdfe9", + "timestamp": "2026-01-03T02:42:00Z" + }, + { + "version": "1.0.0.4", + "changelog": "", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.0.0.4/Jellyfin.Plugin.MediaBar.zip", + "checksum": "e05f0024fe66a9b271c216c3555ce9ff", + "timestamp": "2026-01-03T02:15:29Z" + }, + { + "version": "1.0.0.3", + "changelog": "", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.0.0.3/Jellyfin.Plugin.MediaBar.zip", + "checksum": "c652dba434689a1116e10010235fa48c", + "timestamp": "2026-01-03T01:42:53Z" + }, + { + "version": "1.0.0.2", + "changelog": "", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.0.0.2/Jellyfin.Plugin.MediaBar.zip", + "checksum": "aded8f61ac5aed3449190cf7c41aa693", + "timestamp": "2026-01-03T01:13:21Z" + }, + { + "version": "1.0.0.1", + "changelog": "", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.0.0.1/Jellyfin.Plugin.MediaBar.zip", + "checksum": "8401a51d26ae1906a3eae9f8ff72e9a3", + "timestamp": "2026-01-02T22:45:28Z" + }, + { + "version": "1.0.0.0", + "changelog": "", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/Media-Bar-Plugin/releases/download/v1.0.0.0/Jellyfin.Plugin.MediaBar.zip", + "checksum": "3cbd8527a0cb191dbc4a5126b49b60f9", + "timestamp": "2026-01-02T22:08:37Z" + } + ] + } +] diff --git a/release_automation_remote repo.yml b/release_automation_remote repo.yml new file mode 100644 index 0000000..4d65495 --- /dev/null +++ b/release_automation_remote repo.yml @@ -0,0 +1,157 @@ +name: Auto Release Plugin + +on: + push: + branches: + - main + paths-ignore: + - '.github/**' + - 'README.md' + - 'jellyfin.ruleset' + - '.gitignore' + - '.editorconfig' + - 'LICENSE' + - 'logo.png' + +jobs: + build-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '9.x' + + - name: Read Version from Manifest + id: read_version + run: | + VERSION=$(jq -r '.[0].versions[0].version' manifest.json) + CHANGELOG=$(jq -r '.[0].versions[0].changelog' manifest.json) + TARGET_ABI=$(jq -r '.[0].versions[0].targetAbi' manifest.json) + + echo "Detected Version: $VERSION" + echo "VERSION=$VERSION" >> $GITHUB_ENV + + # Escape newlines in changelog for GITHUB_ENV + echo "CHANGELOG<> $GITHUB_ENV + echo "$CHANGELOG" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Build and Zip + shell: bash + run: | + # Inject version from manifest into the build + dotnet build Jellyfin.Plugin.MediaBarEnhanced/Jellyfin.Plugin.MediaBarEnhanced.csproj --configuration Release --output bin/Publish /p:Version=${{ env.VERSION }} /p:AssemblyVersion=${{ env.VERSION }} + + cd bin/Publish + zip -r Jellyfin.Plugin.MediaBarEnhanced.zip * + cd ../.. + + # Calculate hash + HASH=$(md5sum bin/Publish/Jellyfin.Plugin.MediaBarEnhanced.zip | awk '{ print $1 }') + TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Export variables for next steps + echo "ZIP_HASH=$HASH" >> $GITHUB_ENV + echo "BUILD_TIME=$TIME" >> $GITHUB_ENV + echo "ZIP_PATH=bin/Publish/Jellyfin.Plugin.MediaBarEnhanced.zip" >> $GITHUB_ENV + + - name: Update Local manifest.json (Optional) + shell: bash + run: | + REPO_OWNER="${{ github.repository_owner }}" + REPO_NAME="${{ github.event.repository.name }}" + VERSION="${{ env.VERSION }}" + DOWNLOAD_URL="https://github.com/$REPO_OWNER/$REPO_NAME/releases/download/v$VERSION/Jellyfin.Plugin.MediaBarEnhanced.zip" + + echo "Updating local manifest.json with:" + echo "Hash: ${{ env.ZIP_HASH }}" + echo "Time: ${{ env.BUILD_TIME }}" + echo "Url: $DOWNLOAD_URL" + + jq --arg hash "${{ env.ZIP_HASH }}" \ + --arg time "${{ env.BUILD_TIME }}" \ + --arg url "$DOWNLOAD_URL" \ + '.[0].versions[0].checksum = $hash | .[0].versions[0].timestamp = $time | .[0].versions[0].sourceUrl = $url' \ + manifest.json > manifest.json.tmp && mv manifest.json.tmp manifest.json + + - name: Commit Local manifest.json + uses: stefanzweifel/git-auto-commit-action@v7 + with: + commit_message: "Update manifest.json for release v${{ env.VERSION }} [skip ci]" + file_pattern: manifest.json + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: "v${{ env.VERSION }}" + name: "v${{ env.VERSION }}" + # body: ${{ env.CHANGELOG }} + files: ${{ env.ZIP_PATH }} + draft: false + prerelease: false + generate_release_notes: true + + # Update Message in Remote Repository + - name: Checkout Central Manifest Repo + uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/jellyfin-plugin-manifest + path: central-manifest + token: ${{ secrets.CENTRAL_REPO_PAT }} + + - name: Update Central Manifest + shell: bash + run: | + cd central-manifest + + # 1. Get info from previous steps + VERSION="${{ env.VERSION }}" + HASH="${{ env.ZIP_HASH }}" + TIME="${{ env.BUILD_TIME }}" + # URL points to the RELEASE we just created in the CURRENT repo + DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/v$VERSION/Jellyfin.Plugin.MediaBarEnhanced.zip" + + # 2. Extract GUID from the *built* artifact or the source manifest (in parent dir) + # We use the source manifest from the checkout in '..' + PLUGIN_GUID=$(jq -r '.[0].guid' ../manifest.json) + + echo "Updating Central Manifest for Plugin GUID: $PLUGIN_GUID" + + # 3. Update the specific plugin entry in the central manifest.json + # This logic finds the object with matching guid, and updates its first version entry. + jq --arg guid "$PLUGIN_GUID" \ + --arg hash "$HASH" \ + --arg time "$TIME" \ + --arg url "$DOWNLOAD_URL" \ + --arg ver "$VERSION" \ + 'map(if .guid == $guid then + .versions[0].version = $ver | + .versions[0].checksum = $hash | + .versions[0].timestamp = $time | + .versions[0].sourceUrl = $url + else . end)' \ + manifest.json > manifest.json.tmp && mv manifest.json.tmp manifest.json + + - name: Commit and Push Central Manifest + run: | + cd central-manifest + git config user.name "GitHub Action" + git config user.email "action@github.com" + + # Check if there are changes + if [[ -n $(git status -s) ]]; then + git add manifest.json + git commit -m "Update MediaBar Enhanced to v${{ env.VERSION }}" + git push + else + echo "No changes to central manifest." + fi