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"; + + /// + /// 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 @@ + + + + + + JellyLMS Remote + + + +

🔊 JellyLMS Remote

+
+

Loading…

+
+ + + + diff --git a/Jellyfin.Plugin.JellyLMS/Web/remote-button.js b/Jellyfin.Plugin.JellyLMS/Web/remote-button.js new file mode 100644 index 0000000..b48fe45 --- /dev/null +++ b/Jellyfin.Plugin.JellyLMS/Web/remote-button.js @@ -0,0 +1,49 @@ +(function () { + function getAuthToken() { + try { + var creds = JSON.parse(localStorage.getItem('jellyfin_credentials')); + var server = creds && creds.Servers && creds.Servers[0]; + return (server && server.AccessToken) || null; + } catch (e) { + return null; + } + } + + function addButton() { + if (document.getElementById('jellylms-remote-btn')) { + return; + } + + var btn = document.createElement('a'); + btn.id = 'jellylms-remote-btn'; + btn.href = '/JellyLms/RemoteControl'; + btn.title = 'Multi-room Remote'; + btn.textContent = '🔊'; + btn.style.cssText = 'position:fixed;bottom:20px;right:20px;width:48px;height:48px;' + + 'border-radius:50%;background:#00a4dc;color:#fff;display:flex;' + + 'align-items:center;justify-content:center;font-size:22px;' + + 'text-decoration:none;z-index:99999;box-shadow:0 2px 8px rgba(0,0,0,0.5);'; + document.body.appendChild(btn); + } + + function init() { + var token = getAuthToken(); + if (!token) { + return; + } + + fetch('/JellyLms/RemoteControl/Access', { headers: { 'X-Emby-Token': token } }) + .then(function (resp) { + if (resp.ok) { + addButton(); + } + }) + .catch(function () {}); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();