From 4d2f7df21732cf96013bd62b739f82f26e760204 Mon Sep 17 00:00:00 2001
From: Duncan Tourolle
Date: Sat, 13 Jun 2026 23:35:46 +0200
Subject: [PATCH] new build system inject controls in homepage
---
.gitea/workflows/build.yaml | 45 +-
.gitea/workflows/release.yaml | 53 ++-
Dockerfile.builder | 19 +
.../Api/JellyLmsController.cs | 148 ++++++-
.../Configuration/PluginConfiguration.cs | 7 +
.../Configuration/configPage.html | 15 +
.../Jellyfin.Plugin.JellyLMS.csproj | 4 +
Jellyfin.Plugin.JellyLMS/Plugin.cs | 11 +-
.../Services/WebClientPatchService.cs | 72 ++++
.../Web/RemoteControl.html | 393 ++++++++++++++++++
Jellyfin.Plugin.JellyLMS/Web/remote-button.js | 49 +++
11 files changed, 775 insertions(+), 41 deletions(-)
create mode 100644 Dockerfile.builder
create mode 100644 Jellyfin.Plugin.JellyLMS/Services/WebClientPatchService.cs
create mode 100644 Jellyfin.Plugin.JellyLMS/Web/RemoteControl.html
create mode 100644 Jellyfin.Plugin.JellyLMS/Web/remote-button.js
diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml
index 23ec02c..68990f0 100644
--- a/.gitea/workflows/build.yaml
+++ b/.gitea/workflows/build.yaml
@@ -15,44 +15,51 @@ on:
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: linux/amd64
+ container:
+ image: gitea.tourolle.paris/dtourolle/jellylms-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
+ with:
+ path: build-${{ github.run_id }}
- - name: Verify .NET installation
- run: dotnet --version
+ - name: Cache NuGet packages
+ uses: actions/cache@v3
+ with:
+ path: ~/.nuget/packages
+ key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JellyLMS.csproj') }}
+ restore-keys: nuget-
- name: Restore dependencies
+ working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
- name: Build solution
- run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained
-
- - name: Install JPRM
- run: |
- python3 -m venv /tmp/jprm-venv
- /tmp/jprm-venv/bin/pip install jprm
+ working-directory: build-${{ github.run_id }}
+ run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Build Jellyfin Plugin
id: jprm
+ working-directory: build-${{ github.run_id }}
run: |
- # Create artifacts directory for JPRM output
mkdir -p artifacts
-
- # Build plugin using JPRM
- /tmp/jprm-venv/bin/jprm --verbosity=debug plugin build .
-
- # Find the generated zip file
- ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
- echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
- echo "Found artifact: ${ARTIFACT}"
+ jprm --verbosity=debug plugin build .
+ ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
+ LATEST="artifacts/jellylms_latest.zip"
+ cp "${ARTIFACT}" "${LATEST}"
+ echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
+ echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellylms-plugin
- path: ${{ steps.jprm.outputs.artifact }}
+ path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
retention-days: 30
if-no-files-found: error
+
+ - name: Cleanup
+ if: always()
+ run: rm -rf build-${{ github.run_id }}
diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml
index 76de92c..e568dc5 100644
--- a/.gitea/workflows/release.yaml
+++ b/.gitea/workflows/release.yaml
@@ -13,14 +13,15 @@ on:
jobs:
build-and-release:
- runs-on: ubuntu-latest
+ runs-on: linux/amd64
+ container:
+ image: gitea.tourolle.paris/dtourolle/jellylms-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
-
- - name: Verify .NET installation
- run: dotnet --version
+ with:
+ path: release-${{ github.run_id }}
- name: Get version
id: get_version
@@ -35,39 +36,41 @@ jobs:
echo "Building version: ${VERSION}"
- name: Update build.yaml with version
+ working-directory: release-${{ github.run_id }}
run: |
VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
+ - name: Cache NuGet packages
+ uses: actions/cache@v3
+ with:
+ path: ~/.nuget/packages
+ key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JellyLMS.csproj') }}
+ restore-keys: nuget-
+
- name: Restore dependencies
+ working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
- name: Build solution
- run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained
-
- - name: Install JPRM
- run: |
- python3 -m venv /tmp/jprm-venv
- /tmp/jprm-venv/bin/pip install jprm
+ working-directory: release-${{ github.run_id }}
+ run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Build Jellyfin Plugin
id: jprm
+ working-directory: release-${{ github.run_id }}
run: |
- # Create artifacts directory for JPRM output
mkdir -p artifacts
-
- # Build plugin using JPRM
- /tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./
-
- # Find the generated zip file
- ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
+ jprm --verbosity=debug plugin build ./
+ ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Create Release
+ working-directory: release-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -126,13 +129,20 @@ jobs:
- name: Calculate checksum
id: checksum
+ working-directory: release-${{ github.run_id }}
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "MD5 checksum: ${CHECKSUM}"
- name: Update manifest.json
+ working-directory: release-${{ github.run_id }}
run: |
+ git config user.name "Gitea Actions"
+ git config user.email "actions@gitea.tourolle.paris"
+ git fetch origin master
+ git checkout master
+
VERSION="${{ steps.get_version.outputs.version_number }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
@@ -163,11 +173,14 @@ jobs:
cat manifest.json
- name: Commit and push manifest
+ working-directory: release-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- git config user.name "Gitea Actions"
- git config user.email "actions@gitea.tourolle.paris"
git add manifest.json
git commit -m "Update manifest.json for ${{ steps.get_version.outputs.version }}"
- git push origin HEAD:master
+ git push origin master
+
+ - name: Cleanup
+ if: always()
+ run: rm -rf release-${{ github.run_id }}
diff --git a/Dockerfile.builder b/Dockerfile.builder
new file mode 100644
index 0000000..6179bdb
--- /dev/null
+++ b/Dockerfile.builder
@@ -0,0 +1,19 @@
+# JellyLMS Builder Image
+# Pre-built image with .NET SDK and JPRM for building Jellyfin plugins
+# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellylms-builder:latest .
+# Push: docker push gitea.tourolle.paris/dtourolle/jellylms-builder:latest
+
+FROM mcr.microsoft.com/dotnet/sdk:9.0
+
+RUN apt-get update && apt-get install -y \
+ python3 \
+ python3-pip \
+ git \
+ jq \
+ nodejs \
+ npm \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN pip install --break-system-packages jprm
+
+WORKDIR /src
diff --git a/Jellyfin.Plugin.JellyLMS/Api/JellyLmsController.cs b/Jellyfin.Plugin.JellyLMS/Api/JellyLmsController.cs
index 751c810..224f3c0 100644
--- a/Jellyfin.Plugin.JellyLMS/Api/JellyLmsController.cs
+++ b/Jellyfin.Plugin.JellyLMS/Api/JellyLmsController.cs
@@ -3,8 +3,10 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Net.Mime;
+using System.Reflection;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.JellyLMS.Models;
using Jellyfin.Plugin.JellyLMS.Services;
using MediaBrowser.Controller.Entities;
@@ -29,6 +31,7 @@ public class JellyLmsController : ControllerBase
private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager;
private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
///
/// Initializes a new instance of the class.
@@ -36,14 +39,54 @@ public class JellyLmsController : ControllerBase
/// The LMS API client.
/// The player manager.
/// The library manager.
+ /// The user manager.
public JellyLmsController(
ILmsApiClient lmsClient,
LmsPlayerManager playerManager,
- ILibraryManager libraryManager)
+ ILibraryManager libraryManager,
+ IUserManager userManager)
{
_lmsClient = lmsClient;
_playerManager = playerManager;
_libraryManager = libraryManager;
+ _userManager = userManager;
+ }
+
+ ///
+ /// Determines whether the current user is allowed to use the multi-room remote
+ /// control features (administrators, or users granted the
+ /// "Allow remote control of other users" permission).
+ ///
+ /// true if the user may use remote control endpoints.
+ private bool HasRemoteControlAccess()
+ {
+ var username = User.Identity?.Name;
+ if (string.IsNullOrEmpty(username))
+ {
+ return false;
+ }
+
+ var user = _userManager.GetUserByName(username);
+ if (user is null)
+ {
+ return false;
+ }
+
+ return HasPermission(user, PermissionKind.IsAdministrator)
+ || HasPermission(user, PermissionKind.EnableRemoteControlOfOtherUsers);
+ }
+
+ private static bool HasPermission(Jellyfin.Database.Implementations.Entities.User user, PermissionKind kind)
+ {
+ foreach (var permission in user.Permissions)
+ {
+ if (permission.Kind == kind)
+ {
+ return permission.Value;
+ }
+ }
+
+ return false;
}
///
@@ -65,8 +108,14 @@ public class JellyLmsController : ControllerBase
/// List of players.
[HttpGet("Players")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task>> GetPlayers([FromQuery] bool refresh = false)
{
+ if (!HasRemoteControlAccess())
+ {
+ return Forbid();
+ }
+
var players = await _playerManager.GetPlayersAsync(refresh).ConfigureAwait(false);
return Ok(players);
}
@@ -78,9 +127,15 @@ public class JellyLmsController : ControllerBase
/// The player details.
[HttpGet("Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> GetPlayer(string mac)
{
+ if (!HasRemoteControlAccess())
+ {
+ return Forbid();
+ }
+
var player = await _playerManager.GetPlayerAsync(mac).ConfigureAwait(false);
if (player == null)
{
@@ -98,8 +153,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/PowerOn")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task PowerOn(string mac)
{
+ if (!HasRemoteControlAccess())
+ {
+ return Forbid();
+ }
+
var success = await _lmsClient.PowerOnAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power on player");
}
@@ -112,8 +173,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/PowerOff")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task PowerOff(string mac)
{
+ if (!HasRemoteControlAccess())
+ {
+ return Forbid();
+ }
+
var success = await _lmsClient.PowerOffAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power off player");
}
@@ -127,8 +194,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/Volume")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task SetVolume(string mac, [FromBody] VolumeRequest request)
{
+ if (!HasRemoteControlAccess())
+ {
+ return Forbid();
+ }
+
var success = await _lmsClient.SetVolumeAsync(mac, request.Volume).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to set volume");
}
@@ -139,8 +212,14 @@ public class JellyLmsController : ControllerBase
/// List of sync groups.
[HttpGet("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task>> GetSyncGroups()
{
+ if (!HasRemoteControlAccess())
+ {
+ return Forbid();
+ }
+
var groups = await _playerManager.GetSyncGroupsAsync().ConfigureAwait(false);
return Ok(groups);
}
@@ -153,8 +232,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task CreateSyncGroup([FromBody] CreateSyncGroupRequest request)
{
+ if (!HasRemoteControlAccess())
+ {
+ return Forbid();
+ }
+
var success = await _playerManager.CreateSyncGroupAsync(request.MasterMac, request.SlaveMacs)
.ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to create sync group");
@@ -168,8 +253,14 @@ public class JellyLmsController : ControllerBase
[HttpDelete("SyncGroups/Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task UnsyncPlayer(string mac)
{
+ if (!HasRemoteControlAccess())
+ {
+ return Forbid();
+ }
+
var success = await _playerManager.UnsyncPlayerAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to unsync player");
}
@@ -182,12 +273,67 @@ public class JellyLmsController : ControllerBase
[HttpDelete("SyncGroups/{masterMac}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task DissolveSyncGroup(string masterMac)
{
+ if (!HasRemoteControlAccess())
+ {
+ return Forbid();
+ }
+
var success = await _playerManager.DissolveSyncGroupAsync(masterMac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to dissolve sync group");
}
+ ///
+ /// Checks whether the current user is allowed to use the multi-room remote control.
+ /// Used by the remote control page and the injected web client button to decide
+ /// whether to show themselves.
+ ///
+ /// 200 OK if allowed, otherwise 403 Forbidden.
+ [HttpGet("RemoteControl/Access")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public ActionResult CheckRemoteControlAccess()
+ {
+ return HasRemoteControlAccess() ? Ok() : Forbid();
+ }
+
+ ///
+ /// Serves the standalone multi-room remote control page. The page itself contains
+ /// no sensitive data; it authenticates API calls using the Jellyfin access token
+ /// stored by the web client, so it is reachable without a prior Jellyfin auth header.
+ ///
+ /// The remote control HTML page.
+ [HttpGet("RemoteControl")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult GetRemoteControlPage()
+ {
+ return ServeEmbeddedResource("Jellyfin.Plugin.JellyLMS.Web.RemoteControl.html", "text/html");
+ }
+
+ ///
+ /// Serves the client script that is injected into the Jellyfin web client to add a
+ /// floating button linking to the remote control page.
+ ///
+ /// The client script.
+ [HttpGet("RemoteControl/ClientScript")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult GetRemoteControlClientScript()
+ {
+ return ServeEmbeddedResource("Jellyfin.Plugin.JellyLMS.Web.remote-button.js", "application/javascript");
+ }
+
+ private FileStreamResult ServeEmbeddedResource(string resourceName, string contentType)
+ {
+ var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName)
+ ?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
+
+ return File(stream, contentType);
+ }
+
///
/// Discovers file paths used by Jellyfin's music libraries.
/// Helps users configure path mappings for direct file access.
diff --git a/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs
index 6585201..0c71428 100644
--- a/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs
+++ b/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs
@@ -36,6 +36,7 @@ public class PluginConfiguration : BasePluginConfiguration
ConnectionTimeoutSeconds = 10;
EnableAutoSync = true;
DefaultPlayerMac = string.Empty;
+ EnableHomeScreenButton = true;
}
///
@@ -75,6 +76,12 @@ public class PluginConfiguration : BasePluginConfiguration
///
public string DefaultPlayerMac { get; set; }
+ ///
+ /// Gets or sets a value indicating whether a floating "Remote" button linking to the
+ /// multi-room remote control page should be injected into the Jellyfin web client.
+ ///
+ public bool EnableHomeScreenButton { get; set; }
+
///
/// Gets or sets the Jellyfin API key for authenticating stream requests from LMS.
///
diff --git a/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html b/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html
index 663c5bb..2508909 100644
--- a/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html
+++ b/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html
@@ -182,6 +182,19 @@
+
+
Multi-Room Remote
+
A standalone remote control page is available at /JellyLms/RemoteControl for any user granted the "Allow remote control of other users" permission (Dashboard > Users).
+
+
+
+
Adds a floating button to the Jellyfin web client that links to the remote control page (for authorized users only). Requires write access to the Jellyfin web root and a page reload to take effect.
+
+
+
Player Sync
Select players to sync together for multi-room audio. Synced players play in perfect sync.
@@ -567,6 +580,7 @@
document.querySelector('#EnableAutoSync').checked = config.EnableAutoSync !== false;
document.querySelector('#DefaultPlayerMac').value = config.DefaultPlayerMac || '';
document.querySelector('#UseDirectFilePath').checked = config.UseDirectFilePath || false;
+ document.querySelector('#EnableHomeScreenButton').checked = config.EnableHomeScreenButton !== false;
// Load path mappings (new list format, with fallback to legacy single mapping)
JellyLmsConfig.pathMappings = config.PathMappings || [];
@@ -622,6 +636,7 @@
config.EnableAutoSync = document.querySelector('#EnableAutoSync').checked;
config.DefaultPlayerMac = document.querySelector('#DefaultPlayerMac').value;
config.UseDirectFilePath = document.querySelector('#UseDirectFilePath').checked;
+ config.EnableHomeScreenButton = document.querySelector('#EnableHomeScreenButton').checked;
// Save path mappings (clear legacy single mapping when using list)
config.PathMappings = getPathMappingsFromUI();
config.JellyfinMediaPath = '';
diff --git a/Jellyfin.Plugin.JellyLMS/Jellyfin.Plugin.JellyLMS.csproj b/Jellyfin.Plugin.JellyLMS/Jellyfin.Plugin.JellyLMS.csproj
index 30496d5..dece144 100644
--- a/Jellyfin.Plugin.JellyLMS/Jellyfin.Plugin.JellyLMS.csproj
+++ b/Jellyfin.Plugin.JellyLMS/Jellyfin.Plugin.JellyLMS.csproj
@@ -28,6 +28,10 @@
+
+
+
+
diff --git a/Jellyfin.Plugin.JellyLMS/Plugin.cs b/Jellyfin.Plugin.JellyLMS/Plugin.cs
index 36aa6a1..7b426d5 100644
--- a/Jellyfin.Plugin.JellyLMS/Plugin.cs
+++ b/Jellyfin.Plugin.JellyLMS/Plugin.cs
@@ -2,10 +2,12 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.JellyLMS.Configuration;
+using Jellyfin.Plugin.JellyLMS.Services;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
+using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS;
@@ -15,15 +17,22 @@ namespace Jellyfin.Plugin.JellyLMS;
///
public class Plugin : BasePlugin, IHasWebPages
{
+ private readonly ILogger _logger;
+
///
/// Initializes a new instance of the class.
///
/// Instance of the interface.
/// Instance of the interface.
- public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
+ /// The logger.
+ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger logger)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
+ _logger = logger;
+
+ WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableHomeScreenButton, _logger);
+ ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableHomeScreenButton, _logger);
}
///
diff --git a/Jellyfin.Plugin.JellyLMS/Services/WebClientPatchService.cs b/Jellyfin.Plugin.JellyLMS/Services/WebClientPatchService.cs
new file mode 100644
index 0000000..c69743c
--- /dev/null
+++ b/Jellyfin.Plugin.JellyLMS/Services/WebClientPatchService.cs
@@ -0,0 +1,72 @@
+using System;
+using System.IO;
+using MediaBrowser.Common.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.JellyLMS.Services;
+
+///
+/// Injects (or removes) a script tag in the web client's index.html
+/// that adds a floating button linking to the JellyLMS remote control page.
+/// This follows the pattern used by other Jellyfin plugins (e.g. Intro Skipper)
+/// since there is no official plugin hook for adding buttons to the web client.
+///
+public static class WebClientPatchService
+{
+ private const string Marker = "";
+ private const string ScriptTag = "";
+ private const string Injected = ScriptTag + Marker + "\n
+
🔊 JellyLMS Remote
+
+
Loading…
+
+
+
+
";
+
+ ///
+ /// Ensures the web client's index.html either has or does not have the
+ /// JellyLMS remote button script injected, matching .
+ ///
+ /// The Jellyfin application paths.
+ /// Whether the remote button script should be present.
+ /// The logger.
+ public static void Apply(IApplicationPaths applicationPaths, bool enableButton, ILogger logger)
+ {
+ var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
+
+ try
+ {
+ if (!File.Exists(indexPath))
+ {
+ logger.LogDebug("JellyLMS: web client index.html not found at {Path}", indexPath);
+ return;
+ }
+
+ var html = File.ReadAllText(indexPath);
+ var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
+
+ if (enableButton && !hasMarker)
+ {
+ var patched = ReplaceLast(html, "", Injected);
+ File.WriteAllText(indexPath, patched);
+ logger.LogInformation("JellyLMS: injected remote control button into {Path}", indexPath);
+ }
+ else if (!enableButton && hasMarker)
+ {
+ var patched = html.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
+ .Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
+ File.WriteAllText(indexPath, patched);
+ logger.LogInformation("JellyLMS: removed remote control button from {Path}", indexPath);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ logger.LogWarning(ex, "JellyLMS: failed to patch web client index.html at {Path}", indexPath);
+ }
+ }
+
+ private static string ReplaceLast(string source, string find, string replace)
+ {
+ var index = source.LastIndexOf(find, StringComparison.Ordinal);
+ if (index < 0)
+ {
+ return source;
+ }
+
+ return source[..index] + replace + source[(index + find.Length)..];
+ }
+}
diff --git a/Jellyfin.Plugin.JellyLMS/Web/RemoteControl.html b/Jellyfin.Plugin.JellyLMS/Web/RemoteControl.html
new file mode 100644
index 0000000..84af06a
--- /dev/null
+++ b/Jellyfin.Plugin.JellyLMS/Web/RemoteControl.html
@@ -0,0 +1,393 @@
+
+
+