diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Api/OverlayImageController.cs b/Jellyfin.Plugin.MediaBarEnhanced/Api/OverlayImageController.cs index cb80d67..c222d0e 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Api/OverlayImageController.cs +++ b/Jellyfin.Plugin.MediaBarEnhanced/Api/OverlayImageController.cs @@ -16,7 +16,6 @@ namespace Jellyfin.Plugin.MediaBarEnhanced.Api { private readonly IApplicationPaths _applicationPaths; private readonly string _imageDirectory; - private readonly string _imagePath; public OverlayImageController(IApplicationPaths applicationPaths) { @@ -25,8 +24,8 @@ namespace Jellyfin.Plugin.MediaBarEnhanced.Api // We use the plugin's data folder to store the image _imageDirectory = MediaBarEnhancedPlugin.Instance?.DataFolderPath ?? Path.Combine(applicationPaths.DataPath, "plugins", "MediaBarEnhanced"); - // We'll just overwrite this single file each time - _imagePath = Path.Combine(_imageDirectory, "custom_overlay_image.dat"); + // We no longer define the exact path here, just the directory + // The filename is determined per request } /// @@ -34,13 +33,18 @@ namespace Jellyfin.Plugin.MediaBarEnhanced.Api /// [HttpPost("OverlayImage")] [Consumes("multipart/form-data")] - public async Task UploadImage([FromForm] IFormFile file) + public async Task UploadImage([FromForm] IFormFile file, [FromQuery] string? filename = null) { if (file == null || file.Length == 0) { return BadRequest("No file uploaded."); } + string targetFileName = string.IsNullOrWhiteSpace(filename) + ? "custom_overlay_image.dat" + : $"custom_overlay_image_{filename}.dat"; + string targetPath = Path.Combine(_imageDirectory, targetFileName); + try { if (!Directory.Exists(_imageDirectory)) @@ -51,13 +55,14 @@ namespace Jellyfin.Plugin.MediaBarEnhanced.Api // Delete is not strictly necessary and can cause locking issues if someone is currently reading it. // FileMode.Create will truncate the file if it exists, effectively overwriting it. // We use FileShare.None to ensure we have exclusive write access, but handle potential IOExceptions gracefully. - using (var stream = new FileStream(_imagePath, FileMode.Create, FileAccess.Write, FileShare.None)) + using (var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None)) { await file.CopyToAsync(stream).ConfigureAwait(false); } // Return the GET URL that the frontend can use - var getUrl = "/MediaBarEnhanced/OverlayImage?t=" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var qs = string.IsNullOrWhiteSpace(filename) ? "" : $"?filename={Uri.EscapeDataString(filename)}&"; + var getUrl = $"/MediaBarEnhanced/OverlayImage{qs}{(qs == "" ? "?" : "")}t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; return Ok(new { url = getUrl }); } catch (Exception ex) @@ -70,9 +75,14 @@ namespace Jellyfin.Plugin.MediaBarEnhanced.Api /// Retrieves the custom overlay image. /// [HttpGet("OverlayImage")] - public IActionResult GetImage() + public IActionResult GetImage([FromQuery] string? filename = null) { - if (!System.IO.File.Exists(_imagePath)) + string targetFileName = string.IsNullOrWhiteSpace(filename) + ? "custom_overlay_image.dat" + : $"custom_overlay_image_{filename}.dat"; + string targetPath = Path.Combine(_imageDirectory, targetFileName); + + if (!System.IO.File.Exists(targetPath)) { return NotFound(); } @@ -80,10 +90,78 @@ namespace Jellyfin.Plugin.MediaBarEnhanced.Api // Read the file and return as a generic octet stream. // We use FileShare.ReadWrite so that if someone is currently overwriting the file (uploading), we don't block them, // and we also don't get blocked by other readers. - var stream = new FileStream(_imagePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var stream = new FileStream(targetPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); // "image/*" works reliably as browsers will sniff the exact image mime type (jpeg, png, webp). return File(stream, "image/*"); } + + /// + /// Deletes a custom overlay image. + /// + [HttpDelete("OverlayImage")] + public IActionResult DeleteImage([FromQuery] string? filename = null) + { + string targetFileName = string.IsNullOrWhiteSpace(filename) + ? "custom_overlay_image.dat" + : $"custom_overlay_image_{filename}.dat"; + string targetPath = Path.Combine(_imageDirectory, targetFileName); + + if (System.IO.File.Exists(targetPath)) + { + try + { + System.IO.File.Delete(targetPath); + return Ok(); + } + catch (Exception ex) + { + return StatusCode(500, $"Error deleting file: {ex.Message}"); + } + } + + return NotFound(); + } + + /// + /// Renames a custom overlay image (used when a seasonal section is renamed). + /// + [HttpPut("OverlayImage/Rename")] + public IActionResult RenameImage([FromQuery] string oldName, [FromQuery] string newName) + { + if (string.IsNullOrWhiteSpace(oldName) || string.IsNullOrWhiteSpace(newName)) + { + return BadRequest("Both oldName and newName must be provided."); + } + + string oldPath = Path.Combine(_imageDirectory, $"custom_overlay_image_{oldName}.dat"); + string newPath = Path.Combine(_imageDirectory, $"custom_overlay_image_{newName}.dat"); + + if (!System.IO.File.Exists(oldPath)) + { + // If it doesn't exist, there is nothing to rename, but we still consider it a success + // since the end state (file with oldName is gone, file with newName doesn't exist yet) is acceptable. + return Ok(); + } + + try + { + // If a file with the new name already exists, delete it first to avoid conflicts + if (System.IO.File.Exists(newPath)) + { + System.IO.File.Delete(newPath); + } + + System.IO.File.Move(oldPath, newPath); + + var qs = $"?filename={Uri.EscapeDataString(newName)}&"; + var getUrl = $"/MediaBarEnhanced/OverlayImage{qs}t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + return Ok(new { url = getUrl }); + } + catch (Exception ex) + { + return StatusCode(500, $"Error renaming file: {ex.Message}"); + } + } } } diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html index 723bba1..2e16926 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html +++ b/Jellyfin.Plugin.MediaBarEnhanced/Configuration/configPage.html @@ -236,6 +236,10 @@ + + + +
Choose the visual styling animation for your custom text.
@@ -263,7 +267,7 @@
Uploading an image will securely save it to the server and automatically update the URL field above.
@@ -702,6 +706,15 @@ clearBtn.addEventListener('click', function(e) { e.stopPropagation(); // prevent triggering file dialog + + // Call DELETE API to remove global image + fetch(ApiClient.serverAddress() + '/MediaBarEnhanced/OverlayImage', { + method: 'DELETE', + headers: { + 'Authorization': 'MediaBrowser Client="' + ApiClient.appName() + '", Device="' + ApiClient.deviceName() + '", DeviceId="' + ApiClient.deviceId() + '", Version="' + ApiClient.appVersion() + '", Token="' + ApiClient.accessToken() + '"' + } + }).catch(console.error); + urlInput.value = ''; fileInput.value = ''; updatePreview(); @@ -918,6 +931,17 @@ '
' + ' ' + '
Optional: Override the global custom overlay image during this season. Overrides the text if provided.
' + + '
' + + '
' + + '
' + + ' cloud_upload' + + ' Drag and drop a seasonal image here, or click' + + ' ' + + ' ' + + ' ' + + '
' + '
'; div.querySelector('.btn-remove').addEventListener('click', function () { @@ -939,6 +963,163 @@ } }); + // --- Seasonal Drag and Drop Logic --- + var sectionNameInput = div.querySelector('.section-name'); + var urlInput = div.querySelector('.section-overlay-image'); + var dropzone = div.querySelector('.seasonal-dropzone'); + var fileInput = div.querySelector('.seasonal-file-input'); + var previewImg = div.querySelector('.seasonal-preview-img'); + var clearBtn = div.querySelector('.seasonal-clear-btn'); + var currentSectionName = sectionNameInput.value.trim(); + + // Track Name Changes to rename server file + sectionNameInput.addEventListener('focus', function() { + currentSectionName = this.value.trim(); + }); + + sectionNameInput.addEventListener('blur', function() { + var newName = this.value.trim(); + if (newName && currentSectionName && newName !== currentSectionName) { + // If they have an image attached, rename it + if (urlInput.value && urlInput.value.indexOf('OverlayImage') !== -1) { + fetch(ApiClient.serverAddress() + '/MediaBarEnhanced/OverlayImage/Rename?oldName=' + encodeURIComponent(currentSectionName) + '&newName=' + encodeURIComponent(newName), { + method: 'PUT', + 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('Rename failed'); + }) + .then(data => { + urlInput.value = ApiClient.serverAddress() + data.url; + currentSectionName = newName; + }).catch(console.error); + } else { + currentSectionName = newName; + } + } else { + currentSectionName = newName; + } + }); + + function updatePreview() { + var val = urlInput.value.trim(); + if (val) { + previewImg.src = val; + previewImg.style.display = 'block'; + clearBtn.style.display = 'block'; + } else { + previewImg.src = ''; + previewImg.style.display = 'none'; + clearBtn.style.display = 'none'; + } + } + + // Initial state + updatePreview(); + urlInput.addEventListener('input', updatePreview); + + clearBtn.addEventListener('click', function(e) { + e.stopPropagation(); + var name = sectionNameInput.value.trim(); + if (name) { + fetch(ApiClient.serverAddress() + '/MediaBarEnhanced/OverlayImage?filename=' + encodeURIComponent(name), { + method: 'DELETE', + headers: { + 'Authorization': 'MediaBrowser Client="' + ApiClient.appName() + '", Device="' + ApiClient.deviceName() + '", DeviceId="' + ApiClient.deviceId() + '", Version="' + ApiClient.appVersion() + '", Token="' + ApiClient.accessToken() + '"' + } + }).catch(console.error); + } + urlInput.value = ''; + fileInput.value = ''; + updatePreview(); + }); + + div.querySelector('.btn-remove').addEventListener('click', function () { + // Cleanup image if deleted + var name = sectionNameInput.value.trim(); + if (name) { + fetch(ApiClient.serverAddress() + '/MediaBarEnhanced/OverlayImage?filename=' + encodeURIComponent(name), { + method: 'DELETE', + headers: { + 'Authorization': 'MediaBrowser Client="' + ApiClient.appName() + '", Device="' + ApiClient.deviceName() + '", DeviceId="' + ApiClient.deviceId() + '", Version="' + ApiClient.appVersion() + '", Token="' + ApiClient.accessToken() + '"' + } + }).catch(console.error); + } + div.remove(); + MediaBarEnhancedConfigurationPage.updateSectionTitles(container); + }); + + 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; + uploadSeasonalImage(e.dataTransfer.files[0]); + } + }); + + fileInput.addEventListener('change', function() { + if (this.files && this.files.length > 0) uploadSeasonalImage(this.files[0]); + }); + + function uploadSeasonalImage(file) { + if (!file.type.match('image.*')) { + Dashboard.alert('Please select a valid image file.'); + return; + } + + var name = sectionNameInput.value.trim(); + if (!name) { + Dashboard.alert('Please enter a Name for this season before uploading an image (used for file saving).'); + return; + } + + Dashboard.showLoadingMsg(); + var formData = new FormData(); + formData.append('file', file); + + var qs = '?filename=' + encodeURIComponent(name); + fetch(ApiClient.serverAddress() + '/MediaBarEnhanced/OverlayImage' + qs, { + 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('Upload failed'); + }) + .then(data => { + urlInput.value = ApiClient.serverAddress() + data.url; + updatePreview(); + Dashboard.hideLoadingMsg(); + }) + .catch(error => { + console.error('Upload error:', error); + Dashboard.alert('Image upload failed.'); + Dashboard.hideLoadingMsg(); + }); + } + container.appendChild(div); }, diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.css b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.css index a6ea251..e5e9362 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.css +++ b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.css @@ -1153,3 +1153,98 @@ transform: scale(1.05); } } + + +/* Text Overlay Styles */ +.custom-overlay-style-Neon { + color: #fff; + text-shadow: + 0 0 5px #fff, + 0 0 10px #fff, + 0 0 20px #ff00de, + 0 0 40px #ff00de, + 0 0 80px #ff00de, + 0 0 90px #ff00de, + 0 0 100px #ff00de, + 0 0 150px #ff00de; + animation: flickerNeon 1.5s infinite alternate; +} + +@keyframes flickerNeon { + 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { + text-shadow: + 0 0 5px #fff, + 0 0 10px #fff, + 0 0 20px #ff00de, + 0 0 40px #ff00de, + 0 0 80px #ff00de, + 0 0 90px #ff00de, + 0 0 100px #ff00de, + 0 0 150px #ff00de; + } + 20%, 24%, 55% { + text-shadow: none; + } +} + +.custom-overlay-style-Typewriter { + font-family: 'Courier New', Courier, monospace; + background-color: #222; + color: #00ff00; + padding: 10px 20px; + border: 2px solid #00ff00; + border-radius: 4px; + box-shadow: 4px 4px 0px #00ff00; + text-transform: uppercase; +} + +.custom-overlay-style-Bubble { + color: #fff; + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); + padding: 12px 30px; + border-radius: 100px; + border: 2px solid rgba(255, 255, 255, 0.5); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 0 20px rgba(255,255,255,0.2); + text-shadow: 1px 1px 2px rgba(0,0,0,0.8); + animation: floatBubble 4s ease-in-out infinite; +} + +@keyframes floatBubble { + 0% { transform: translateY(0px); } + 50% { transform: translateY(-15px); } + 100% { transform: translateY(0px); } +} + +.custom-overlay-style-SlideIn { + color: #fff; + text-transform: uppercase; + letter-spacing: 5px; + text-shadow: 2px 2px 4px rgba(0,0,0,0.8); + position: relative; + animation: slideInCinematic 1.2s cubic-bezier(0.25, 1, 0.5, 1) forwards; +} + +.custom-overlay-style-SlideIn::before { + content: ''; + position: absolute; + top: -10px; + bottom: -10px; + left: -50vw; + right: -50px; + background: linear-gradient(to right, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.8) 70%, transparent 100%); + z-index: -1; + border-left: 5px solid #00a4dc; +} + +@keyframes slideInCinematic { + from { + transform: translateX(-100vw); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js index 44758a9..afc6f22 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js +++ b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js @@ -3825,8 +3825,14 @@ const slidesInit = async () => { if (isActive) { if (section.OverlayText || section.OverlayImageUrl) { isSeasonOverride = true; - if (section.OverlayText) activeOverlayText = section.OverlayText; - if (section.OverlayImageUrl) activeOverlayImage = section.OverlayImageUrl; + // If the season has an image, clear text, and vice versa. + if (section.OverlayImageUrl) { + activeOverlayImage = section.OverlayImageUrl; + activeOverlayText = null; + } else if (section.OverlayText) { + activeOverlayText = section.OverlayText; + activeOverlayImage = null; + } } break; }