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("Config")] [Produces("application/json")] public IActionResult GetConfig() { var config = Plugin.Instance!.Configuration; return Ok(new { cropDetectLimit = config.CropDetectLimit, playbackMode = config.PlaybackMode, subtitleOffsetVh = config.SubtitleOffsetVh, maxParallelScans = config.MaxParallelScans }); } [HttpGet("ClientScript.js")] [Produces("application/javascript")] public ContentResult GetClientScript() { double defaultSubOffset = Plugin.Instance!.Configuration.SubtitleOffsetVh; string defaultSubOffsetStr = defaultSubOffset.ToString(System.Globalization.CultureInfo.InvariantCulture); 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; window._configuredSubtitleOffsetVh = {SUB_OFFSET}; window._customSubtitleOffsetVh = null; function isTranscoding() { if (window._isVideoDirect === false) { return true; } if (window._isVideoDirect === true) { return false; } return false; } function fetchServerConfig() { if (!window._originalFetch) return; window._originalFetch('/BlackBarRemover/Config') .then(r => r.json()) .then(data => { if (data && typeof data.subtitleOffsetVh === 'number') { const newOffset = data.subtitleOffsetVh; if (newOffset !== window._configuredSubtitleOffsetVh) { window._configuredSubtitleOffsetVh = newOffset; console.log('Black Bar Remover: Synced configured subtitle offset:', newOffset + 'vh'); if (window._customSubtitleOffsetVh === null && lastBottomPercent > 0) { updateSubtitleStyles(lastBottomPercent); } } } }) .catch(e => {}); } function showSubtitleToast(msg) { let toastEl = document.getElementById('black-bar-remover-sub-toast'); if (!toastEl) { toastEl = document.createElement('div'); toastEl.id = 'black-bar-remover-sub-toast'; toastEl.style.position = 'fixed'; toastEl.style.bottom = '15%'; toastEl.style.left = '50%'; toastEl.style.transform = 'translateX(-50%)'; toastEl.style.backgroundColor = 'rgba(0, 0, 0, 0.85)'; toastEl.style.color = '#fff'; toastEl.style.padding = '8px 16px'; toastEl.style.borderRadius = '20px'; toastEl.style.fontSize = '0.95rem'; toastEl.style.fontWeight = 'bold'; toastEl.style.zIndex = '999999'; toastEl.style.pointerEvents = 'none'; toastEl.style.transition = 'opacity 0.3s ease'; document.body.appendChild(toastEl); } toastEl.textContent = msg; toastEl.style.opacity = '1'; if (window._toastTimeout) clearTimeout(window._toastTimeout); window._toastTimeout = setTimeout(() => { toastEl.style.opacity = '0'; }, 2000); } function getActiveOffsetVh() { if (window._customSubtitleOffsetVh !== null && window._customSubtitleOffsetVh !== undefined) { return window._customSubtitleOffsetVh; } if (window._configuredSubtitleOffsetVh !== null && window._configuredSubtitleOffsetVh !== undefined) { return window._configuredSubtitleOffsetVh; } return 5.0; } if (!window._subHotkeyBound) { window._subHotkeyBound = true; window.addEventListener('keydown', function(e) { if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { const video = document.querySelector('video'); if (!video) return; e.preventDefault(); let current = getActiveOffsetVh(); if (e.key === 'ArrowUp') { current = Math.min(25.0, current + 0.5); } else if (e.key === 'ArrowDown') { current = Math.max(0.0, current - 0.5); } window._customSubtitleOffsetVh = Math.round(current * 10) / 10; showSubtitleToast('Subtitle Offset: ' + window._customSubtitleOffsetVh.toFixed(1) + ' vh (Alt+Up/Down)'); if (lastBottomPercent > 0) { updateSubtitleStyles(lastBottomPercent); } } }); } 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 activeOffset = getActiveOffsetVh(); const offsetVh = activeOffset.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 activeOffset = getActiveOffsetVh(); const targetLine = Math.max(10, 95 - Math.round(activeOffset * 1.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(() => { fetchServerConfig(); 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); })(); "; js = js.Replace("{SUB_OFFSET}", defaultSubOffsetStr); return Content(js, "application/javascript"); } } }