diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Api/OverlayImageController.cs b/Jellyfin.Plugin.MediaBarEnhanced/Api/OverlayImageController.cs new file mode 100644 index 0000000..31a8a49 --- /dev/null +++ b/Jellyfin.Plugin.MediaBarEnhanced/Api/OverlayImageController.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Plugin.MediaBarEnhanced.Api +{ + /// + /// Controller for handling custom overlay image uploads and retrieval. + /// + [ApiController] + [Route("MediaBarEnhanced")] + public class OverlayImageController : ControllerBase + { + private readonly IApplicationPaths _applicationPaths; + private readonly string _imageDirectory; + private readonly string _imagePath; + + public OverlayImageController(IApplicationPaths applicationPaths) + { + _applicationPaths = applicationPaths; + + // We use the plugin's data folder to store the image + _imageDirectory = Path.Combine(_applicationPaths.PluginConfigurationPath, "MediaBarEnhanced"); + + // We'll just overwrite this single file each time + _imagePath = Path.Combine(_imageDirectory, "custom_overlay_image.dat"); + } + + /// + /// Uploads a new custom overlay image. + /// + [HttpPost("OverlayImage")] + [Consumes("multipart/form-data")] + public async Task UploadImage([FromForm] IFormFile file) + { + if (file == null || file.Length == 0) + { + return BadRequest("No file uploaded."); + } + + try + { + if (!Directory.Exists(_imageDirectory)) + { + Directory.CreateDirectory(_imageDirectory); + } + + // Delete the old one if it exists to ensure freshness + if (System.IO.File.Exists(_imagePath)) + { + System.IO.File.Delete(_imagePath); + } + + using (var stream = new FileStream(_imagePath, FileMode.Create)) + { + await file.CopyToAsync(stream).ConfigureAwait(false); + } + + // Return the GET URL that the frontend can use + var getUrl = "/MediaBarEnhanced/OverlayImage?t=" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return Ok(new { url = getUrl }); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Retrieves the custom overlay image. + /// + [HttpGet("OverlayImage")] + public IActionResult GetImage() + { + if (!System.IO.File.Exists(_imagePath)) + { + return NotFound(); + } + + // Read the file and return as a generic octet stream, since we don't strictly track the mime type + // The browser will figure out it's an image + var stream = new FileStream(_imagePath, FileMode.Open, FileAccess.Read, FileShare.Read); + return File(stream, "image/*"); + } + } +} diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/PluginConfiguration.cs index 3e75b62..11e1026 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/PluginConfiguration.cs @@ -53,5 +53,6 @@ namespace Jellyfin.Plugin.MediaBarEnhanced.Configuration public bool EnableCustomOverlay { get; set; } = false; public string CustomOverlayText { get; set; } = ""; public string CustomOverlayImageUrl { get; set; } = ""; + public string CustomOverlayStyle { get; set; } = "Shadowed"; } } diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html index a450d08..752af7c 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html +++ b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html @@ -210,10 +210,10 @@ - - + + Custom Slideshow Overlay - Inject a custom text or floating image over the slideshow. This can be overridden by specific Seasonal Sections. + Inject a custom text or floating image over the slideshow. This can be overridden by specific Seasonal Sections in the Custom Filters tab. @@ -223,18 +223,51 @@ If enabled, the text or image below will hover over the slideshow globally. + + Overlay Style + + Classic Shadowed + Frosted Glass Pill + Cinematic Golden Glow + Animated Pulse + + Choose the visual styling animation for your custom text. + + + Global Overlay Text Text to display on the overlay (e.g. "Movie Night!"). Leave blank to use an image instead. + Global Overlay Image + Global Overlay Image URL - Absolute URL to an image to display on the overlay. If provided, this overrides the text. + Absolute URL to an image to display on the overlay. If provided, this overrides the text above. - + + + Or Upload Local Image + + cloud_upload + Drag and drop an image here, or click to select + + + + + close + + + Uploading an image will securely save it to the server and automatically update the URL field above. + + + + Features @@ -558,7 +591,8 @@ 'PreferLocalBackdrops', 'RandomizeThemeVideos', 'RandomizeLocalTrailers', 'IncludeWatchedContent', 'ShowPaginationDots', 'MaxParentalRating', 'MaxDaysRecent', 'ExcludeSeasonalContent', 'HideArrowsOnMobile', - 'EnableCustomOverlay', 'CustomOverlayText', 'CustomOverlayImageUrl' + 'EnableCustomOverlay', 'CustomOverlayText', 'CustomOverlayImageUrl', + 'CustomOverlayStyle' ]; // Manual mapping for MediaBarIsEnabled -> IsEnabled, to avoid conflicts with other plugins @@ -638,6 +672,108 @@ updatePreferLocalVisibility(); } + // Overlay Image Upload Logic + var dropzone = page.querySelector('#overlayImageDropzone'); + var fileInput = page.querySelector('#overlayImageInput'); + var urlInput = page.querySelector('#CustomOverlayImageUrl'); + var previewImg = page.querySelector('#overlayImagePreview'); + var clearBtn = page.querySelector('#clearOverlayImageBtn'); + + function updatePreview() { + if (urlInput.value && urlInput.value.trim() !== '') { + previewImg.src = urlInput.value; + previewImg.style.display = 'block'; + clearBtn.style.display = 'block'; + } else { + previewImg.style.display = 'none'; + clearBtn.style.display = 'none'; + } + } + + // Initial check + updatePreview(); + + // Listen to manual URL input changes + urlInput.addEventListener('input', updatePreview); + + clearBtn.addEventListener('click', function(e) { + e.stopPropagation(); // prevent triggering file dialog + urlInput.value = ''; + fileInput.value = ''; + updatePreview(); + }); + + dropzone.addEventListener('click', function() { + fileInput.click(); + }); + + dropzone.addEventListener('dragover', function(e) { + e.preventDefault(); + dropzone.style.borderColor = '#00a4dc'; + dropzone.style.background = 'rgba(0, 164, 220, 0.2)'; + }); + + dropzone.addEventListener('dragleave', function(e) { + e.preventDefault(); + dropzone.style.borderColor = 'rgba(255,255,255,0.2)'; + dropzone.style.background = 'rgba(0,0,0,0.2)'; + }); + + dropzone.addEventListener('drop', function(e) { + e.preventDefault(); + dropzone.style.borderColor = 'rgba(255,255,255,0.2)'; + dropzone.style.background = 'rgba(0,0,0,0.2)'; + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + fileInput.files = e.dataTransfer.files; + uploadImage(e.dataTransfer.files[0]); + } + }); + + fileInput.addEventListener('change', function() { + if (this.files && this.files.length > 0) { + uploadImage(this.files[0]); + } + }); + + function uploadImage(file) { + // Validate it's an image + if (!file.type.match('image.*')) { + Dashboard.alert('Please select a valid image file.'); + return; + } + + Dashboard.showLoadingMsg(); + + var formData = new FormData(); + formData.append('file', file); + + // The Endpoint we created in C# + fetch(ApiClient.serverAddress() + '/MediaBarEnhanced/OverlayImage', { + method: 'POST', + body: formData, + headers: { + 'Authorization': 'MediaBrowser Client="' + ApiClient.appName() + '", Device="' + ApiClient.deviceName() + '", DeviceId="' + ApiClient.deviceId() + '", Version="' + ApiClient.appVersion() + '", Token="' + ApiClient.accessToken() + '"' + } + }) + .then(response => { + if (response.ok) return response.json(); + throw new Error('Network response was not ok.'); + }) + .then(data => { + // Update URL input + urlInput.value = ApiClient.serverAddress() + data.url; + updatePreview(); + Dashboard.hideLoadingMsg(); + }) + .catch(error => { + console.error('Upload error:', error); + Dashboard.alert('Image upload failed. Please verify API controller is active.'); + Dashboard.hideLoadingMsg(); + }); + } + + Dashboard.hideLoadingMsg(); }); }, @@ -670,7 +806,8 @@ 'PreferLocalBackdrops', 'RandomizeThemeVideos', 'RandomizeLocalTrailers', 'IncludeWatchedContent', 'ShowPaginationDots', 'MaxParentalRating', 'MaxDaysRecent', 'ExcludeSeasonalContent', 'HideArrowsOnMobile', - 'EnableCustomOverlay', 'CustomOverlayText', 'CustomOverlayImageUrl' + 'EnableCustomOverlay', 'CustomOverlayText', 'CustomOverlayImageUrl', + 'CustomOverlayStyle' ]; keys.forEach(function (key) { diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.css b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.css index 76af5f9..a6ea251 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.css +++ b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.css @@ -1103,3 +1103,53 @@ } } } + +/* Custom Overlay Styles */ +.custom-overlay-style-Shadowed { + color: #fff; + text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9), -1px -1px 4px rgba(0, 0, 0, 0.8); +} + +.custom-overlay-style-Frosted { + color: #fff; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + padding: 8px 24px; + border-radius: 50px; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + text-shadow: none; /* override default */ +} + +.custom-overlay-style-Cinematic { + background: linear-gradient(to right, #bf953f, #fcf6ba, #b38728, #fbf5b7, #aa771c); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + text-shadow: none; /* override default */ + filter: drop-shadow(0px 2px 8px rgba(255, 215, 0, 0.4)) drop-shadow(2px 2px 4px rgba(0,0,0,0.8)); + animation: shineCinematic 4s linear infinite; + background-size: 200% auto; +} + +@keyframes shineCinematic { + to { + background-position: 200% center; + } +} + +.custom-overlay-style-Pulse { + color: #fff; + text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9), -1px -1px 4px rgba(0, 0, 0, 0.8); + animation: pulseOverlayText 3s ease-in-out infinite alternate; +} + +@keyframes pulseOverlayText { + from { + transform: scale(1); + } + to { + transform: scale(1.05); + } +} diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js index 89f246b..44758a9 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js +++ b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js @@ -61,6 +61,7 @@ const CONFIG = { enableCustomOverlay: false, customOverlayText: "", customOverlayImageUrl: "", + customOverlayStyle: "Shadowed", enableCustomMediaIds: true, enableSeasonalContent: false, customMediaIds: "", @@ -3851,7 +3852,7 @@ const slidesInit = async () => { overlayContainer.appendChild(img); } else if (activeOverlayText) { const p = document.createElement("p"); - p.className = "custom-overlay-text"; + p.className = `custom-overlay-text custom-overlay-style-${CONFIG.customOverlayStyle || 'Shadowed'}`; p.textContent = activeOverlayText; overlayContainer.appendChild(p); }
Inject a custom text or floating image over the slideshow. This can be overridden by specific Seasonal Sections.
Inject a custom text or floating image over the slideshow. This can be overridden by specific Seasonal Sections in the Custom Filters tab.