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; let currentCropData = null; window._currentPlayMethod = null; window._isVideoDirect = null; function isTranscoding() { if (window._isVideoDirect === false) { return true; } if (window._isVideoDirect === true) { return false; } return false; } function ensureBoundingBoxHook(video) { if (!video._origGetBoundingClientRect) { video._origGetBoundingClientRect = video.getBoundingClientRect; video.getBoundingClientRect = function() { if (video._isCroppedByPlugin && video.parentElement) { const rect = video.parentElement.getBoundingClientRect(); return { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, width: rect.width, height: rect.height, x: rect.x !== undefined ? rect.x : rect.left, y: rect.y !== undefined ? rect.y : rect.top, toJSON: function() { return this; } }; } return video._origGetBoundingClientRect.apply(this, arguments); }; } } let lastBottomPercent = 0; function updateSubtitleStyles(bottomPercent) { lastBottomPercent = bottomPercent; let styleEl = document.getElementById('black-bar-remover-sub-style'); if (!styleEl) { styleEl = document.createElement('style'); styleEl.id = 'black-bar-remover-sub-style'; document.head.appendChild(styleEl); } if (bottomPercent > 0) { const offsetVh = (bottomPercent * 0.4 + 2.5).toFixed(2); styleEl.textContent = ` .videoSubtitles { display: flex !important; flex-direction: column !important; justify-content: flex-end !important; transform: translateY(-${offsetVh}vh) !important; } .track-text-output, .custom-subtitle-overlay, .subtitleSyncContainer, .videoSubtitlesContainer, .libassjs-canvas-parent, canvas.libassjs-canvas, .subtitles-octopus-canvas, .htmlvideoplayer::-webkit-media-text-track-container, .htmlvideoplayer::-webkit-media-text-track-display { transform: translateY(-${offsetVh}vh) !important; } `; console.log('Black Bar Remover: Subtitle CSS style updated translateY(-' + offsetVh + 'vh)'); } else { styleEl.textContent = ''; } } function adjustVttCues(bottomPercent) { const video = document.querySelector('video'); if (!video || !video.textTracks) return; const targetLine = Math.max(10, 92 - Math.round(bottomPercent * 0.4 + 2.5)); for (let i = 0; i < video.textTracks.length; i++) { const track = video.textTracks[i]; if (track && track.cues) { for (let j = 0; j < track.cues.length; j++) { const cue = track.cues[j]; if (cue && !cue._pluginAdjusted) { cue.snapToLines = false; cue.line = targetLine; cue._pluginAdjusted = true; } } } } } function applyCrop(cropData) { const video = document.querySelector('video'); if (!video) return; if (isTranscoding()) { console.log('Black Bar Remover: Video is transcoded by server (IsVideoDirect: false), skipping CSS crop.'); video.style.position = ''; video.style.objectFit = ''; video.style.width = ''; video.style.height = ''; video.style.top = ''; video.style.left = ''; video.style.clipPath = ''; video.style.transform = ''; updateSubtitleStyles(0); 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]); if (h > 0 && h < 1080) { const heightPercent = (1080 / h) * 100; const topPercent = -(y / h) * 100; const bottomPercent = ((1080 - (y + h)) / 1080) * 100; video.style.position = 'absolute'; video.style.objectFit = 'fill'; video.style.width = '100%'; video.style.height = heightPercent.toFixed(2) + '%'; video.style.top = topPercent.toFixed(2) + '%'; video.style.left = '0%'; video.style.transform = 'none'; video.style.clipPath = 'none'; if (video.parentElement) { video.parentElement.style.overflow = 'hidden'; } updateSubtitleStyles(bottomPercent); adjustVttCues(bottomPercent); console.log('Black Bar Remover: Applied non-scaling crop height:', heightPercent.toFixed(2) + '% top:', topPercent.toFixed(2) + '% subOffset:', (bottomPercent * 0.4 + 2.5).toFixed(2) + 'vh'); } } } 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) { currentCropData = 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 && 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; window._currentPlayMethod = null; window._isVideoDirect = null; console.log('Black Bar Remover: Intercepted itemId from fetch:', id); } } } const res = await window._originalFetch.apply(this, args); if (url && typeof url === 'string' && url.includes('/PlaybackInfo')) { try { const clone = res.clone(); clone.json().then(data => { if (data && data.MediaSources && data.MediaSources[0]) { const ms = data.MediaSources[0]; window._currentPlayMethod = ms.PlayMethod; window._isVideoDirect = ms.IsVideoDirect !== false; console.log('Black Bar Remover: PlaybackInfo detected -> PlayMethod:', window._currentPlayMethod, 'IsVideoDirect:', window._isVideoDirect); if (currentCropData) { applyCrop(currentCropData); } } }).catch(e => {}); } catch(e) {} } return res; }; } 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; window._currentPlayMethod = null; window._isVideoDirect = null; console.log('Black Bar Remover: Intercepted itemId from XHR:', id); } } if (url.includes('/PlaybackInfo')) { xhr.addEventListener('load', function() { try { const data = JSON.parse(xhr.responseText); if (data && data.MediaSources && data.MediaSources[0]) { const ms = data.MediaSources[0]; window._currentPlayMethod = ms.PlayMethod; window._isVideoDirect = ms.IsVideoDirect !== false; console.log('Black Bar Remover: XHR PlaybackInfo detected -> PlayMethod:', window._currentPlayMethod, 'IsVideoDirect:', window._isVideoDirect); if (currentCropData) { applyCrop(currentCropData); } } } catch(e) {} }); } } 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 && lastBottomPercent > 0) { updateSubtitleStyles(lastBottomPercent); adjustVttCues(lastBottomPercent); } else if (!video) { currentItemId = null; lastBottomPercent = 0; window._isServerCropping = false; } }, 2000); })(); "; return Content(js, "application/javascript"); } } }