feat: add live subtitle offset configuration and real-time player hotkeys
Build and Release Plugin / release (push) Successful in 45s
Build and Release Plugin / release (push) Successful in 45s
This commit is contained in:
@@ -61,10 +61,27 @@ namespace JellyfinPlugin.Api
|
|||||||
return Content(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")]
|
[HttpGet("ClientScript.js")]
|
||||||
[Produces("application/javascript")]
|
[Produces("application/javascript")]
|
||||||
public ContentResult GetClientScript()
|
public ContentResult GetClientScript()
|
||||||
{
|
{
|
||||||
|
double defaultSubOffset = Plugin.Instance!.Configuration.SubtitleOffsetVh;
|
||||||
|
string defaultSubOffsetStr = defaultSubOffset.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
string js = @"
|
string js = @"
|
||||||
(function() {
|
(function() {
|
||||||
console.log('Black Bar Remover: Script loaded');
|
console.log('Black Bar Remover: Script loaded');
|
||||||
@@ -73,6 +90,8 @@ namespace JellyfinPlugin.Api
|
|||||||
let currentCropData = null;
|
let currentCropData = null;
|
||||||
window._currentPlayMethod = null;
|
window._currentPlayMethod = null;
|
||||||
window._isVideoDirect = null;
|
window._isVideoDirect = null;
|
||||||
|
window._configuredSubtitleOffsetVh = {SUB_OFFSET};
|
||||||
|
window._customSubtitleOffsetVh = null;
|
||||||
|
|
||||||
function isTranscoding() {
|
function isTranscoding() {
|
||||||
if (window._isVideoDirect === false) {
|
if (window._isVideoDirect === false) {
|
||||||
@@ -84,6 +103,85 @@ namespace JellyfinPlugin.Api
|
|||||||
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) {
|
function ensureBoundingBoxHook(video) {
|
||||||
if (!video._origGetBoundingClientRect) {
|
if (!video._origGetBoundingClientRect) {
|
||||||
video._origGetBoundingClientRect = video.getBoundingClientRect;
|
video._origGetBoundingClientRect = video.getBoundingClientRect;
|
||||||
@@ -109,60 +207,53 @@ namespace JellyfinPlugin.Api
|
|||||||
|
|
||||||
let lastBottomPercent = 0;
|
let lastBottomPercent = 0;
|
||||||
|
|
||||||
function updateSubtitleStyles(bottomPercent) {
|
function updateSubtitleStyles(bottomPercent) {{
|
||||||
lastBottomPercent = bottomPercent;
|
lastBottomPercent = bottomPercent;
|
||||||
let styleEl = document.getElementById('black-bar-remover-sub-style');
|
let styleEl = document.getElementById('black-bar-remover-sub-style');
|
||||||
if (!styleEl) {
|
if (!styleEl) {{
|
||||||
styleEl = document.createElement('style');
|
styleEl = document.createElement('style');
|
||||||
styleEl.id = 'black-bar-remover-sub-style';
|
styleEl.id = 'black-bar-remover-sub-style';
|
||||||
document.head.appendChild(styleEl);
|
document.head.appendChild(styleEl);
|
||||||
}
|
}}
|
||||||
|
|
||||||
if (bottomPercent > 0) {
|
if (bottomPercent > 0) {{
|
||||||
const offsetVh = (bottomPercent * 0.4 + 2.5).toFixed(2);
|
const activeOffset = getActiveOffsetVh();
|
||||||
styleEl.textContent = `
|
const offsetVh = activeOffset.toFixed(2);
|
||||||
.videoSubtitles {
|
styleEl.textContent =
|
||||||
display: flex !important;
|
'.videoSubtitles {' +
|
||||||
flex-direction: column !important;
|
'display: flex !important;' +
|
||||||
justify-content: flex-end !important;
|
'flex-direction: column !important;' +
|
||||||
transform: translateY(-${offsetVh}vh) !important;
|
'justify-content: flex-end !important;' +
|
||||||
}
|
'transform: translateY(-' + offsetVh + 'vh) !important;' +
|
||||||
.track-text-output,
|
'}' +
|
||||||
.custom-subtitle-overlay,
|
'.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 {' +
|
||||||
.subtitleSyncContainer,
|
'transform: translateY(-' + offsetVh + 'vh) !important;' +
|
||||||
.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)');
|
console.log('Black Bar Remover: Subtitle CSS style updated translateY(-' + offsetVh + 'vh)');
|
||||||
} else {
|
}} else {{
|
||||||
styleEl.textContent = '';
|
styleEl.textContent = '';
|
||||||
}
|
}}
|
||||||
}
|
}}
|
||||||
|
|
||||||
function adjustVttCues(bottomPercent) {
|
function adjustVttCues(bottomPercent) {{
|
||||||
const video = document.querySelector('video');
|
const video = document.querySelector('video');
|
||||||
if (!video || !video.textTracks) return;
|
if (!video || !video.textTracks) return;
|
||||||
const targetLine = Math.max(10, 92 - Math.round(bottomPercent * 0.4 + 2.5));
|
const activeOffset = getActiveOffsetVh();
|
||||||
for (let i = 0; i < video.textTracks.length; i++) {
|
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];
|
const track = video.textTracks[i];
|
||||||
if (track && track.cues) {
|
if (track && track.cues) {{
|
||||||
for (let j = 0; j < track.cues.length; j++) {
|
for (let j = 0; j < track.cues.length; j++) {{
|
||||||
const cue = track.cues[j];
|
const cue = track.cues[j];
|
||||||
if (cue && !cue._pluginAdjusted) {
|
if (cue && !cue._pluginAdjusted) {{
|
||||||
cue.snapToLines = false;
|
cue.snapToLines = false;
|
||||||
cue.line = targetLine;
|
cue.line = targetLine;
|
||||||
cue._pluginAdjusted = true;
|
cue._pluginAdjusted = true;
|
||||||
}
|
}}
|
||||||
}
|
}}
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}
|
}}
|
||||||
|
|
||||||
function applyCrop(cropData) {
|
function applyCrop(cropData) {
|
||||||
const video = document.querySelector('video');
|
const video = document.querySelector('video');
|
||||||
@@ -376,6 +467,7 @@ namespace JellyfinPlugin.Api
|
|||||||
playbackInfoObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
|
playbackInfoObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
fetchServerConfig();
|
||||||
const video = document.querySelector('video');
|
const video = document.querySelector('video');
|
||||||
if (video && interceptedItemId && interceptedItemId !== currentItemId) {
|
if (video && interceptedItemId && interceptedItemId !== currentItemId) {
|
||||||
currentItemId = interceptedItemId;
|
currentItemId = interceptedItemId;
|
||||||
@@ -393,6 +485,7 @@ namespace JellyfinPlugin.Api
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
})();
|
})();
|
||||||
";
|
";
|
||||||
|
js = js.Replace("{SUB_OFFSET}", defaultSubOffsetStr);
|
||||||
return Content(js, "application/javascript");
|
return Content(js, "application/javascript");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,8 @@ namespace JellyfinPlugin.Configuration
|
|||||||
|
|
||||||
// Maksymalna liczba równoległych wątków skanowania czarnych pasów (1 - 16, domyślnie 4)
|
// Maksymalna liczba równoległych wątków skanowania czarnych pasów (1 - 16, domyślnie 4)
|
||||||
public int MaxParallelScans { get; set; } = 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@
|
|||||||
<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 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>
|
||||||
|
|
||||||
|
<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>
|
<div>
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
@@ -63,6 +69,7 @@
|
|||||||
if (mode === "Both") mode = "Auto";
|
if (mode === "Both") mode = "Auto";
|
||||||
$('#selectPlaybackMode', page).val(mode);
|
$('#selectPlaybackMode', page).val(mode);
|
||||||
$('#txtMaxParallelScans', page).val(config.MaxParallelScans || 4);
|
$('#txtMaxParallelScans', page).val(config.MaxParallelScans || 4);
|
||||||
|
$('#txtSubtitleOffsetVh', page).val(config.SubtitleOffsetVh !== undefined ? config.SubtitleOffsetVh : 5.0);
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -77,6 +84,7 @@
|
|||||||
config.CropDetectLimit = parseFloat($('#txtCropLimit', form).val());
|
config.CropDetectLimit = parseFloat($('#txtCropLimit', form).val());
|
||||||
config.PlaybackMode = $('#selectPlaybackMode', form).val();
|
config.PlaybackMode = $('#selectPlaybackMode', form).val();
|
||||||
config.MaxParallelScans = parseInt($('#txtMaxParallelScans', form).val(), 10) || 4;
|
config.MaxParallelScans = parseInt($('#txtMaxParallelScans', form).val(), 10) || 4;
|
||||||
|
config.SubtitleOffsetVh = parseFloat($('#txtSubtitleOffsetVh', form).val());
|
||||||
|
|
||||||
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
|
|||||||
@@ -70,11 +70,13 @@ This plugin has been verified and tested on:
|
|||||||
1. Go to **Dashboard** -> **Plugins** -> **Black Bar Remover**.
|
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`.
|
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. 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.
|
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. Select your preferred **Playback Mode**:
|
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.
|
- `Auto`: Lightweight CSS clipping for Web browser DirectPlay; FFmpeg transcoding for native apps.
|
||||||
- `Client`: Uses CSS injection in the web player.
|
- `Client`: Uses CSS injection in the web player.
|
||||||
- `Server`: Requires setting up the FFmpeg wrapper.
|
- `Server`: Requires setting up the FFmpeg wrapper.
|
||||||
5. **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)
|
### Activating the Server-Side Wrapper (Automatic)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user