using System; using System.Linq; using System.Net.Mime; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JellyfinPlugin.Api { [ApiController] [Route("BlackBarRemover")] public class BlackBarController : ControllerBase { private readonly ILibraryManager _libraryManager; private readonly ILogger _logger; public BlackBarController(ILibraryManager libraryManager, ILogger logger) { _libraryManager = libraryManager; _logger = logger; } [HttpGet("CropByPath")] [Produces("text/plain")] public ContentResult GetCropByPath([FromQuery] string path) { if (string.IsNullOrEmpty(path)) return Content(string.Empty, "text/plain"); path = path.Trim('"').Trim('\''); var mode = Plugin.Instance!.Configuration.PlaybackMode; if (mode == "Client") return Content(string.Empty, "text/plain"); var item = _libraryManager.RootFolder.GetRecursiveChildren().FirstOrDefault(x => x.Path == path); if (item != null && item.ProviderIds != null && item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData)) { return Content(cropData ?? string.Empty, "text/plain"); } return Content(string.Empty, "text/plain"); } [HttpGet("CropById")] [Produces("text/plain")] public ContentResult GetCropById([FromQuery] string itemId, [FromQuery] bool ignoreMode = false) { if (string.IsNullOrEmpty(itemId)) return Content(string.Empty, "text/plain"); var mode = Plugin.Instance!.Configuration.PlaybackMode; if (mode == "Server" && !ignoreMode) return Content(string.Empty, "text/plain"); if (Guid.TryParse(itemId, out Guid id)) { var item = _libraryManager.GetItemById(id); if (item != null && item.ProviderIds != null && item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData)) { return Content(cropData ?? string.Empty, "text/plain"); } } return Content(string.Empty, "text/plain"); } [HttpGet("ClientScript.js")] [Produces("application/javascript")] public ContentResult GetClientScript() { string js = @" (function() { console.log('Black Bar Remover: Script loaded'); let currentItemId = null; let interceptedItemId = null; function isTranscoding() { try { if (window.playbackManager) { const currentPlayer = window.playbackManager.getCurrentPlayer(); if (currentPlayer && typeof currentPlayer.getPlayerState === 'function') { const state = currentPlayer.getPlayerState(); if (state && state.PlayMethod) { return state.PlayMethod === 'Transcode' || state.PlayMethod === 'DirectStream'; } } } } catch (e) {} const video = document.querySelector('video'); if (video && video.src) { const src = video.src.toLowerCase(); if (src.includes('transcode') || src.includes('.m3u8') || src.includes('transcodereasons')) { return true; } } return false; } function applyCrop(cropData) { const video = document.querySelector('video'); if (!video) return; if (isTranscoding()) { console.log('Black Bar Remover: Video is transcoded by server, skipping CSS crop.'); video.style.clipPath = ''; video.style.transform = ''; return; } const parts = cropData.split(':'); if (parts.length === 4) { const w = parseInt(parts[0]); const h = parseInt(parts[1]); const x = parseInt(parts[2]); const y = parseInt(parts[3]); const topPercent = (y / 1080) * 100; const bottomPercent = ((1080 - (y + h)) / 1080) * 100; const leftPercent = (x / 1920) * 100; const rightPercent = ((1920 - (x + w)) / 1920) * 100; video.style.clipPath = `inset(${topPercent}% ${rightPercent}% ${bottomPercent}% ${leftPercent}%)`; const scaleY = 1 / (1 - (topPercent + bottomPercent)/100); const scaleX = 1 / (1 - (leftPercent + rightPercent)/100); const scale = Math.max(scaleY, scaleX); if (scale > 1.01) { video.style.transform = `scale(${scale})`; } console.log('Black Bar Remover: Applied crop', cropData, 'scale', scale); } } function fetchCrop(itemId) { console.log('Black Bar Remover: Fetching crop data for item', itemId); window._originalFetch('/BlackBarRemover/CropById?itemId=' + itemId) .then(r => r.text()) .then(crop => { console.log('Black Bar Remover: Received crop data:', crop); if (crop) { setTimeout(() => applyCrop(crop), 500); setTimeout(() => applyCrop(crop), 2000); } }) .catch(e => console.error('Black Bar Remover: API fetch error:', e)); } if (!window._originalFetch) { window._originalFetch = window.fetch; window.fetch = async function(...args) { const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url); if (url) { const match = url.match(/\/Items\/([a-f0-9]{32}|[a-f0-9\-]{36})/i); if (match) { const id = match[1]; if (id && id !== interceptedItemId) { interceptedItemId = id; console.log('Black Bar Remover: Intercepted itemId from fetch:', id); } } } return window._originalFetch.apply(this, args); }; } if (!window._originalXHR) { window._originalXHR = window.XMLHttpRequest; window.XMLHttpRequest = function() { const xhr = new window._originalXHR(); const originalOpen = xhr.open; xhr.open = function(method, url, ...rest) { if (url && typeof url === 'string') { const match = url.match(/\/Items\/([a-f0-9]{32}|[a-f0-9\-]{36})/i); if (match) { const id = match[1]; if (id && id !== interceptedItemId) { interceptedItemId = id; console.log('Black Bar Remover: Intercepted itemId from XHR:', id); } } } return originalOpen.apply(this, [method, url, ...rest]); }; return xhr; }; } function checkServerCrop(itemId) { window._originalFetch('/BlackBarRemover/CropById?itemId=' + itemId + '&ignoreMode=true') .then(r => r.text()) .then(crop => { if (crop) { window._isServerCropping = true; } else { window._isServerCropping = false; } }) .catch(e => { window._isServerCropping = false; }); } const playbackInfoObserver = new MutationObserver((mutations) => { if (!window._isServerCropping) return; for (let m of mutations) { if (m.type === 'characterData') { if (m.target.nodeValue) { const text = m.target.nodeValue.trim(); if (text === ""The video's resolution is not supported"" || text === ""VideoResolutionNotSupported"" || text.includes(""rozdzielczość nie jest obsługiwana"") || text === ""Rozdzielczość wideo nie jest obsługiwana"") { m.target.nodeValue = ""Black Bar Removal (Server Mode)""; } } } else if (m.type === 'childList') { m.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { const textContent = node.textContent || """"; if (textContent.includes(""The video's resolution is not supported"") || textContent.includes(""VideoResolutionNotSupported"") || textContent.includes(""rozdzielczość"")) { const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false); let textNode; while (textNode = walker.nextNode()) { if (textNode.nodeValue) { const t = textNode.nodeValue.trim(); if (t === ""The video's resolution is not supported"" || t === ""VideoResolutionNotSupported"" || t.includes(""rozdzielczość nie jest obsługiwana"") || t === ""Rozdzielczość wideo nie jest obsługiwana"") { textNode.nodeValue = ""Black Bar Removal (Server Mode)""; } } } } } else if (node.nodeType === Node.TEXT_NODE) { if (node.nodeValue) { const t = node.nodeValue.trim(); if (t === ""The video's resolution is not supported"" || t === ""VideoResolutionNotSupported"" || t.includes(""rozdzielczość nie jest obsługiwana"") || t === ""Rozdzielczość wideo nie jest obsługiwana"") { node.nodeValue = ""Black Bar Removal (Server Mode)""; } } } }); } } }); playbackInfoObserver.observe(document.body, { childList: true, subtree: true, characterData: true }); setInterval(() => { const video = document.querySelector('video'); if (video && interceptedItemId && interceptedItemId !== currentItemId) { currentItemId = interceptedItemId; console.log('Black Bar Remover: Video element found for intercepted item:', currentItemId); fetchCrop(currentItemId); checkServerCrop(currentItemId); } else if (!video) { currentItemId = null; window._isServerCropping = false; } }, 2000); })(); "; return Content(js, "application/javascript"); } } }