493 lines
21 KiB
C#
493 lines
21 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("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");
|
|
}
|
|
}
|
|
}
|