Compare commits

...

5 Commits

Author SHA1 Message Date
karol 526ee3d165 feat: add live subtitle offset configuration and real-time player hotkeys
Build and Release Plugin / release (push) Successful in 45s
2026-09-19 22:25:12 +02:00
karol d1691655a4 docs: highlight plugin repository URL with GitHub alert box 2026-09-18 00:44:03 +02:00
karol d0a6669179 feat: add configurable multi-threaded parallel scanning for library tasks 2026-09-18 00:17:24 +02:00
Gitea Actions 359aa21394 Update manifest.json for v0.1.21-beta 2026-09-18 00:16:10 +02:00
Gitea Actions 0d5c55c843 Update manifest.json for v0.1.20-beta 2026-09-17 23:46:33 +02:00
7 changed files with 238 additions and 67 deletions
+130 -37
View File
@@ -61,10 +61,27 @@ namespace JellyfinPlugin.Api
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');
@@ -73,6 +90,8 @@ namespace JellyfinPlugin.Api
let currentCropData = null;
window._currentPlayMethod = null;
window._isVideoDirect = null;
window._configuredSubtitleOffsetVh = {SUB_OFFSET};
window._customSubtitleOffsetVh = null;
function isTranscoding() {
if (window._isVideoDirect === false) {
@@ -84,6 +103,85 @@ namespace JellyfinPlugin.Api
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;
@@ -109,60 +207,53 @@ namespace JellyfinPlugin.Api
let lastBottomPercent = 0;
function updateSubtitleStyles(bottomPercent) {
function updateSubtitleStyles(bottomPercent) {{
lastBottomPercent = bottomPercent;
let styleEl = document.getElementById('black-bar-remover-sub-style');
if (!styleEl) {
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;
}
`;
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 {
}} else {{
styleEl.textContent = '';
}
}
}}
}}
function adjustVttCues(bottomPercent) {
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 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++) {
if (track && track.cues) {{
for (let j = 0; j < track.cues.length; j++) {{
const cue = track.cues[j];
if (cue && !cue._pluginAdjusted) {
if (cue && !cue._pluginAdjusted) {{
cue.snapToLines = false;
cue.line = targetLine;
cue._pluginAdjusted = true;
}
}
}}
}}
}
}
}
}}
}}
function applyCrop(cropData) {
const video = document.querySelector('video');
@@ -376,6 +467,7 @@ namespace JellyfinPlugin.Api
playbackInfoObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
setInterval(() => {
fetchServerConfig();
const video = document.querySelector('video');
if (video && interceptedItemId && interceptedItemId !== currentItemId) {
currentItemId = interceptedItemId;
@@ -393,6 +485,7 @@ namespace JellyfinPlugin.Api
}, 2000);
})();
";
js = js.Replace("{SUB_OFFSET}", defaultSubOffsetStr);
return Content(js, "application/javascript");
}
}
@@ -12,5 +12,11 @@ namespace JellyfinPlugin.Configuration
// Tryb odtwarzania: "Server" (FFmpeg wrapper), "Client" (CSS injection w Web UI), "Auto" (Automatyczny: CSS dla Web, FFmpeg dla aplikacji natywnych)
public string PlaybackMode { get; set; } = "Auto";
// Maksymalna liczba równoległych wątków skanowania czarnych pasów (1 - 16, domyślnie 4)
public int MaxParallelScans { get; set; } = 4;
// Przesunięcie pionowe napisów w vh w trybie klienckim CSS (0.0 - 25.0, domyślnie 5.0)
public double SubtitleOffsetVh { get; set; } = 5.0;
}
}
@@ -33,6 +33,18 @@
<div class="fieldDescription">Choose how the plugin should remove black bars. Auto mode uses lightweight CSS clipping for Web browser DirectPlay, and falls back to FFmpeg transcoding for native apps or unsupported video formats.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtMaxParallelScans">Max Parallel Scans (1 - 16):</label>
<input type="number" step="1" min="1" max="16" id="txtMaxParallelScans" is="emby-input" required />
<div class="fieldDescription">Number of files to analyze concurrently during library scheduled tasks. Default is 4. Higher values speed up library scans on multi-core CPUs.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtSubtitleOffsetVh">Client Subtitle Offset (0.0 - 25.0 vh):</label>
<input type="number" step="0.5" min="0.0" max="25.0" id="txtSubtitleOffsetVh" is="emby-input" required />
<div class="fieldDescription">Vertical offset shift for subtitles in Jellyfin Web player (in viewport height % / vh). Default is 5.0. Increase to move subtitles higher up into the video, or decrease if they appear too high.</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
@@ -56,6 +68,8 @@
var mode = config.PlaybackMode || "Auto";
if (mode === "Both") mode = "Auto";
$('#selectPlaybackMode', page).val(mode);
$('#txtMaxParallelScans', page).val(config.MaxParallelScans || 4);
$('#txtSubtitleOffsetVh', page).val(config.SubtitleOffsetVh !== undefined ? config.SubtitleOffsetVh : 5.0);
Dashboard.hideLoadingMsg();
});
});
@@ -69,6 +83,8 @@
config.IsEnabled = $('#chkEnablePlugin', form).prop('checked');
config.CropDetectLimit = parseFloat($('#txtCropLimit', form).val());
config.PlaybackMode = $('#selectPlaybackMode', form).val();
config.MaxParallelScans = parseInt($('#txtMaxParallelScans', form).val(), 10) || 4;
config.SubtitleOffsetVh = parseFloat($('#txtSubtitleOffsetVh', form).val());
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
+14 -8
View File
@@ -42,15 +42,21 @@ namespace JellyfinPlugin.Tasks
.ToList();
int total = items.Count;
if (total == 0) return;
int processed = 0;
int maxParallel = Math.Clamp(config.MaxParallelScans, 1, 16);
foreach (var item in items)
var parallelOptions = new ParallelOptions
{
cancellationToken.ThrowIfCancellationRequested();
// Wymuszamy skanowanie, więc nie sprawdzamy czy ma już BlackBarCrop
MaxDegreeOfParallelism = maxParallel,
CancellationToken = cancellationToken
};
_logger.LogInformation("Starting Force Rescan of {Total} items with {MaxParallel} parallel workers.", total, maxParallel);
await Parallel.ForEachAsync(items, parallelOptions, async (item, ct) =>
{
var path = item.Path;
if (!string.IsNullOrEmpty(path) && File.Exists(path))
{
@@ -65,7 +71,7 @@ namespace JellyfinPlugin.Tasks
item.ProviderIds = new Dictionary<string, string>();
}
item.ProviderIds["BlackBarCrop"] = cropData;
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, ct);
_logger.LogInformation("Found black bars {Crop} in {Path}", cropData, path);
}
}
@@ -75,9 +81,9 @@ namespace JellyfinPlugin.Tasks
}
}
processed++;
progress.Report((processed / (double)total) * 100);
}
int current = Interlocked.Increment(ref processed);
progress.Report((current / (double)total) * 100);
});
}
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
@@ -42,19 +42,28 @@ namespace JellyfinPlugin.Tasks
.ToList();
int total = items.Count;
if (total == 0) return;
int processed = 0;
int maxParallel = Math.Clamp(config.MaxParallelScans, 1, 16);
foreach (var item in items)
var parallelOptions = new ParallelOptions
{
cancellationToken.ThrowIfCancellationRequested();
MaxDegreeOfParallelism = maxParallel,
CancellationToken = cancellationToken
};
_logger.LogInformation("Starting Retroactive Scan of {Total} items with {MaxParallel} parallel workers.", total, maxParallel);
await Parallel.ForEachAsync(items, parallelOptions, async (item, ct) =>
{
if (item.ProviderIds != null &&
item.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
!string.IsNullOrEmpty(existingCrop))
{
processed++;
progress.Report((processed / (double)total) * 100);
continue;
int currentSkipped = Interlocked.Increment(ref processed);
progress.Report((currentSkipped / (double)total) * 100);
return;
}
var path = item.Path;
@@ -71,7 +80,7 @@ namespace JellyfinPlugin.Tasks
item.ProviderIds = new Dictionary<string, string>();
}
item.ProviderIds["BlackBarCrop"] = cropData;
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, ct);
_logger.LogInformation("Found black bars {Crop} in {Path}", cropData, path);
}
}
@@ -81,9 +90,9 @@ namespace JellyfinPlugin.Tasks
}
}
processed++;
progress.Report((processed / (double)total) * 100);
}
int currentDone = Interlocked.Increment(ref processed);
progress.Report((currentDone / (double)total) * 100);
});
}
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+18 -9
View File
@@ -26,12 +26,18 @@ This plugin has been verified and tested on:
### Method 1: Jellyfin Repository (Recommended & Easiest)
1. Open your Jellyfin Dashboard and navigate to **Plugins** -> **Repositories**.
2. Click the `+` icon to add a new repository.
3. Enter `Black Bar Remover` as the name.
4. Enter `https://gitea.labus.uk/karol/blackBarRemover/raw/branch/master/manifest.json` as the Repository URL and save.
5. Go back to the **Catalog** tab, find "Black Bar Remover" under the Playback category, and click **Install**.
6. Restart the Jellyfin server.
> [!TIP]
> **Plugin Repository URL**:
> ```text
> https://gitea.labus.uk/karol/blackBarRemover/raw/branch/master/manifest.json
> ```
1. Open your Jellyfin Dashboard and navigate to **Dashboard** -> **Plugins** -> **Repositories** (`Repositories` tab).
2. Click the **`+`** icon to add a new repository.
3. Enter **`Black Bar Remover`** as the Repository Name.
4. Copy and paste the **Repository URL** above into the **Repository URL** field, then click **Save**.
5. Switch to the **Catalog** tab, find **Black Bar Remover** (under the *Playback* category), and click **Install**.
6. Restart your Jellyfin server.
### Method 2: Manual Installation from Releases
@@ -63,11 +69,14 @@ This plugin has been verified and tested on:
1. Go to **Dashboard** -> **Plugins** -> **Black Bar Remover**.
2. Set your **Crop Detect Limit** (Default is `0.094`). If your web-dl rips have noisy black bars that are not being cropped, increase this value to `0.15` or `0.20`.
3. Select your preferred **Playback Mode**:
3. Set **Max Parallel Scans** (Default is `4`, Range `1 - 16`). Controls how many video files are scanned concurrently during library tasks to maximize multi-core CPU utilization.
4. Set **Client Subtitle Offset** (Default is `5.0 vh`, Range `0.0 - 25.0 vh`). Adjusts the vertical offset of subtitles when CSS crop is active in the Web player.
5. Select your preferred **Playback Mode**:
- `Auto`: Lightweight CSS clipping for Web browser DirectPlay; FFmpeg transcoding for native apps.
- `Client`: Uses CSS injection in the web player.
- `Server`: Requires setting up the FFmpeg wrapper.
- `Both`: Attempts both methods depending on the client.
4. **Important for existing libraries**: The plugin automatically scans *new* files when they are added to Jellyfin. For your existing library, go to **Dashboard -> Scheduled Tasks** and run the **"Scan Missing Black Bars (Black Bar Remover)"** task manually. It will scan all existing movies and episodes in the background.
6. **Live Subtitle Adjustment during Playback**: While watching a video in Jellyfin Web with CSS crop enabled, you can press **`Alt + ArrowUp`** or **`Alt + ArrowDown`** to adjust the subtitle vertical position on the fly in real-time with on-screen feedback!
7. **Important for existing libraries**: The plugin automatically scans *new* files when they are added to Jellyfin. For your existing library, go to **Dashboard -> Scheduled Tasks** and run the **"Scan Missing Black Bars (Black Bar Remover)"** task manually. It will scan all existing movies and episodes in the background.
### Activating the Server-Side Wrapper (Automatic)
+36 -4
View File
@@ -8,21 +8,53 @@
"category": "Playback",
"imageUrl": "",
"versions": [
{
"version": "0.1.21",
"changelog": "Release v0.1.21-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.21-beta/BlackBarRemover_v0.1.21-beta_jf12.0.zip",
"checksum": "5794f3ca94ae1ee2cf545a347156c6f9",
"timestamp": "2026-09-17T22:16:10Z"
},
{
"version": "0.1.21",
"changelog": "Release v0.1.21-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.21-beta/BlackBarRemover_v0.1.21-beta_jf10.11.zip",
"checksum": "15c4fe17ebe8f5938652968cae0f6ff6",
"timestamp": "2026-09-17T22:16:10Z"
},
{
"version": "0.1.20",
"changelog": "Release v0.1.20-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.20-beta/BlackBarRemover_v0.1.20-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T23:45:19+02:00"
},
{
"version": "0.1.20",
"changelog": "Release v0.1.20-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.20-beta/BlackBarRemover_v0.1.20-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T23:45:19+02:00"
},
{
"version": "0.1.19",
"changelog": "Release v0.1.19-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.19-beta/BlackBarRemover_v0.1.19-beta_jf12.0.zip",
"checksum": "baed36aef30d4cece36d1b36cd67775f",
"timestamp": "2026-09-17T19:57:09Z"
"checksum": "",
"timestamp": "2026-09-17T21:56:15+02:00"
},
{
"version": "0.1.19",
"changelog": "Release v0.1.19-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.19-beta/BlackBarRemover_v0.1.19-beta_jf10.11.zip",
"checksum": "9bb29cf4ba5e351059a92a9a7ac5b85f",
"timestamp": "2026-09-17T19:57:09Z"
"checksum": "",
"timestamp": "2026-09-17T21:56:15+02:00"
},
{
"version": "0.1.18",