Files
blackBarRemover/JellyfinPlugin/Api/BlackBarService.cs
T
2026-09-17 23:45:19 +02:00

400 lines
17 KiB
C#

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<BlackBarController> _logger;
public BlackBarController(ILibraryManager libraryManager, ILogger<BlackBarController> 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");
}
}
}