Add Thumbnail Scrubber configuration UI and Preview on Hover feature

This commit is contained in:
HighImKevin committed 2026-02-12 06:53:47 -05:00
1 parent f8d13fd834
commit cfaa62a7e9
3 files changed
+545 -4

No files matched your search

@@ -0,0 +1,247 @@
// KefinTweaks Thumbnail Scrubber Configuration UI
// Modular configuration modal for managing thumbnail scrubber settings
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks ThumbnailScrubber Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks ThumbnailScrubber Config]', ...args);
const MODAL_ID = 'kefin-thumbnailscrubber-config';
/**
* Load configuration from window.KefinTweaksConfig
*/
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
return config.thumbnailScrubber || {};
}
/**
* Save configuration to JS Injector
*/
async function saveConfig(thumbnailScrubberConfig) {
try {
// Ensure window.KefinTweaksConfig exists
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
// Update thumbnailScrubber config
window.KefinTweaksConfig.thumbnailScrubber = thumbnailScrubberConfig;
// Save to JS Injector using utils
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
} else {
// Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved to JS Injector (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
}
} catch (error) {
ERR('Error saving config:', error);
return false;
}
}
/**
* Build configuration HTML
*/
function buildConfigHTML(config) {
const previewOnHover = config.PreviewOnHover === true;
const hoverDelay = Number.isFinite(config.HOVER_PREVIEW_TIMEOUT_MS * 1)
? config.HOVER_PREVIEW_TIMEOUT_MS * 1
: (Number.isFinite(config.HOVER_TIMEOUT * 1) ? config.HOVER_TIMEOUT * 1 : 800);
const hoverSpeed = Number.isFinite(config.HOVER_PREVIEW_FRAME_MS * 1)
? config.HOVER_PREVIEW_FRAME_MS * 1
: 250;
const scrubDelay = Number.isFinite(config.SCRUB_ACTIVATION_DELAY_MS * 1)
? config.SCRUB_ACTIVATION_DELAY_MS * 1
: 300;
return `
<div style="max-width: 800px;">
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Preview On Hover</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
When enabled, hovering over an eligible card will automatically cycle trickplay thumbnails
as the card background after a short delay, without needing to scrub in the progress zone.
</div>
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="thumbnailScrubber_PreviewOnHover" ${previewOnHover ? 'checked' : ''}>
<span class="listItemBodyText">Enabled</span>
</label>
</div>
</div>
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Delay Before Previewing On Hover (ms)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
How long the cursor must stay over a card before the passive preview starts.
Set to 0 to start immediately when hovering a card.
</div>
<input type="number"
id="thumbnailScrubber_HOVER_PREVIEW_TIMEOUT_MS"
class="fld emby-input"
min="0"
step="50"
value="${hoverDelay}"
style="width: 120px;">
</div>
</div>
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Hover Preview Speed (ms between frames)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
Interval in milliseconds between each trickplay frame during hover preview.
Leave at 0 to use the default trickplay interval from the server.
</div>
<input type="number"
id="thumbnailScrubber_HOVER_PREVIEW_FRAME_MS"
class="fld emby-input"
min="0"
step="50"
value="${hoverSpeed}"
style="width: 120px;">
</div>
</div>
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Delay Before Activating Scrubbing (ms)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
How long the cursor must stay in the scrub zone at the bottom of the card before
the scrubber UI activates.
</div>
<input type="number"
id="thumbnailScrubber_SCRUB_ACTIVATION_DELAY_MS"
class="fld emby-input"
min="0"
step="50"
value="${scrubDelay}"
style="width: 120px;">
</div>
</div>
</div>
`;
}
/**
* Collect configuration from form
*/
function collectConfig() {
const previewOnHover = document.getElementById('thumbnailScrubber_PreviewOnHover')?.checked === true;
const hoverDelayInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_TIMEOUT_MS');
const scrubDelayInput = document.getElementById('thumbnailScrubber_SCRUB_ACTIVATION_DELAY_MS');
const hoverSpeedInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_FRAME_MS');
let hoverDelay = hoverDelayInput ? parseInt(hoverDelayInput.value, 10) : 800;
if (!Number.isFinite(hoverDelay) || hoverDelay < 0) {
hoverDelay = 800;
}
let scrubDelay = scrubDelayInput ? parseInt(scrubDelayInput.value, 10) : 300;
if (!Number.isFinite(scrubDelay) || scrubDelay < 0) {
scrubDelay = 300;
}
let hoverSpeed = hoverSpeedInput ? parseInt(hoverSpeedInput.value, 10) : 0;
if (!Number.isFinite(hoverSpeed) || hoverSpeed < 0) {
hoverSpeed = 0;
}
return {
PreviewOnHover: previewOnHover,
// Delay before previewing on hover
HOVER_PREVIEW_TIMEOUT_MS: hoverDelay,
HOVER_TIMEOUT: hoverDelay, // legacy key
// Hover preview speed (ms between frames)
HOVER_PREVIEW_FRAME_MS: hoverSpeed,
// Scrub activation delay
SCRUB_ACTIVATION_DELAY_MS: scrubDelay
};
}
/**
* Open configuration modal
*/
async function openConfigModal() {
try {
// Load config
const config = await loadConfig();
// Build content
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
// Build footer
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-thumbnailscrubber-config-btn">Save</button>
`;
// Create modal
const modalInstance = window.ModalSystem.create({
id: MODAL_ID,
title: 'Thumbnail Scrubber Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modal) => {
// Save button
const saveBtn = modal.dialogFooter.querySelector('#save-thumbnailscrubber-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const thumbnailScrubberConfig = collectConfig();
const success = await saveConfig(thumbnailScrubberConfig);
if (success) {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Thumbnail Scrubber configuration saved!');
}
window.ModalSystem.close(MODAL_ID);
} else {
alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
});
}
}
});
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '800px';
modalInstance.dialog.style.height = 'auto';
LOG('Thumbnail Scrubber configuration modal opened');
} catch (error) {
ERR('Error opening thumbnail scrubber config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
// Export
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.thumbnailScrubber = { openConfigModal };
console.log('[KefinTweaks ThumbnailScrubber Config] Script loaded');
})();
+9 -2
View File
@@ -338,11 +338,18 @@
dependencies: [],
description: 'Fixes the dashboard button to redirect to the home page when the back button is clicked and there is no history to go back to'
},
{
name: 'thumbnailScrubber-configuration',
script: '../configuration/thumbnailScrubber-configuration.js',
css: null,
dependencies: ['modal', 'toaster', 'utils'],
description: 'Thumbnail scrubber configuration for KefinTweaks'
},
{
name: 'thumbnailScrubber',
script: 'thumbnailScrubber.js',
css: null,
dependencies: [],
dependencies: ['thumbnailScrubber-configuration'],
description: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card for 2+ seconds'
},
{
@@ -750,7 +757,7 @@
}
async function loadConfigurationJS() {
const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'skinManager-configuration', 'customMenuLinks-configuration'];
const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration'];
for (const depName of configDependencyNames) {
const depScript = SCRIPT_DEFINITIONS.find(script => script.name === depName);
if (depScript) {
+289 -2
View File
@@ -12,6 +12,11 @@
const SCRUB_ACTIVATION_DELAY_MS = 300;
const PROGRESS_BAR_WIDTH_PERCENT = 95;
// Passive hover preview defaults (can be overridden by config)
const HOVER_PREVIEW_DEFAULT_ENABLED = false;
const HOVER_PREVIEW_DEFAULT_TIMEOUT_MS = 800;
const HOVER_PREVIEW_DEFAULT_FRAME_MS = 333;
const VIDEO_TYPES = ['Movie', 'Episode'];
/** Ticks are 100-nanosecond units; 10_000 ticks = 1 ms */
@@ -30,6 +35,8 @@
*/
let currentCardState = null;
let rafId = null;
let hoverPreviewRafId = null;
let hoverPreviewIntervalId = null;
if (!document.getElementById('kefin-scrubber-styles')) {
const style = document.createElement('style');
@@ -141,10 +148,98 @@
.kefin-scrubber-overlay[data-no-trickplay] {
display: none !important;
}
/* Passive hover preview: dedicated clipped box so only one tile is visible */
.cardImageContainer .kefin-hover-preview-box {
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
margin: 0 auto;
overflow: hidden;
pointer-events: none;
z-index: 18;
}
.cardImageContainer .kefin-hover-preview-frame {
width: 100%;
height: 100%;
background-repeat: no-repeat;
}
`;
document.head.appendChild(style);
}
/**
* Read thumbnail scrubber configuration from global config.
* Returns { previewOnHover: boolean, hoverTimeoutMs: number, scrubActivationDelayMs: number, hoverPreviewFrameMs?: number }.
*/
function getThumbnailScrubberConfig() {
const root = (window.KefinTweaksConfig && window.KefinTweaksConfig.thumbnailScrubber) || {};
const previewOnHover = root.PreviewOnHover === true;
// Hover start delay (ms) - prefer HOVER_PREVIEW_TIMEOUT_MS, fall back to legacy HOVER_TIMEOUT.
const hoverDelayRaw = root.HOVER_PREVIEW_TIMEOUT_MS != null ? root.HOVER_PREVIEW_TIMEOUT_MS : root.HOVER_TIMEOUT;
const hoverTimeoutMs = Number.isFinite(hoverDelayRaw * 1)
? Math.max(0, hoverDelayRaw * 1)
: HOVER_PREVIEW_DEFAULT_TIMEOUT_MS;
// Scrub activation delay override (ms) - fall back to default constant.
const scrubDelayRaw = root.SCRUB_ACTIVATION_DELAY_MS;
const scrubActivationDelayMs = Number.isFinite(scrubDelayRaw * 1) && scrubDelayRaw >= 0
? scrubDelayRaw * 1
: SCRUB_ACTIVATION_DELAY_MS;
// Optional override for hover preview frame speed (ms between frames).
const hoverPreviewFrameMs = Number.isFinite(root.HOVER_PREVIEW_FRAME_MS * 1) && root.HOVER_PREVIEW_FRAME_MS > 0
? root.HOVER_PREVIEW_FRAME_MS * 1
: null;
return {
previewOnHover: previewOnHover ?? HOVER_PREVIEW_DEFAULT_ENABLED,
hoverTimeoutMs,
scrubActivationDelayMs,
hoverPreviewFrameMs
};
}
function isHoverPreviewEnabled() {
try {
return !!getThumbnailScrubberConfig().previewOnHover;
} catch (e) {
WARN('Error reading hover preview config', e);
return HOVER_PREVIEW_DEFAULT_ENABLED;
}
}
function getHoverPreviewTimeoutMs() {
try {
return getThumbnailScrubberConfig().hoverTimeoutMs || HOVER_PREVIEW_DEFAULT_TIMEOUT_MS;
} catch (e) {
WARN('Error reading hover timeout config', e);
return HOVER_PREVIEW_DEFAULT_TIMEOUT_MS;
}
}
function getHoverPreviewFrameIntervalMs() {
try {
const cfg = getThumbnailScrubberConfig();
return cfg.hoverPreviewFrameMs || HOVER_PREVIEW_DEFAULT_FRAME_MS;
} catch (e) {
WARN('Error reading hover frame interval config', e);
return null;
}
}
function getScrubActivationDelayMs() {
try {
return getThumbnailScrubberConfig().scrubActivationDelayMs || SCRUB_ACTIVATION_DELAY_MS;
} catch (e) {
WARN('Error reading scrub activation delay config', e);
return SCRUB_ACTIVATION_DELAY_MS;
}
}
/**
* Format positionTicks as running time (MM:SS or HH:MM:SS).
* Uses Jellyfin's datetime.getDisplayRunningTime if available.
@@ -597,9 +692,41 @@
}
}
function stopHoverPreview() {
if (!currentCardState) return;
const state = currentCardState;
if (state.hoverPreviewTimer) {
clearTimeout(state.hoverPreviewTimer);
state.hoverPreviewTimer = null;
}
state.hoverPreviewRunning = false;
if (hoverPreviewRafId != null) {
cancelAnimationFrame(hoverPreviewRafId);
hoverPreviewRafId = null;
}
if (hoverPreviewIntervalId != null) {
clearInterval(hoverPreviewIntervalId);
hoverPreviewIntervalId = null;
}
if (state.cardImageContainer) {
const box = state.cardImageContainer.querySelector('.kefin-hover-preview-box');
if (box && box._frame) {
box._frame.style.backgroundImage = '';
box._frame.style.backgroundPositionX = '';
box._frame.style.backgroundPositionY = '';
box._frame.style.backgroundSize = '';
}
if (box) {
box.style.height = '';
box.style.width = '';
}
}
}
function teardown() {
if (!currentCardState) return;
clearActivationTimer();
stopHoverPreview();
if (currentCardState.overlay) {
const overlay = currentCardState.overlay;
overlay.classList.remove('is-visible', 'is-zone-active', 'is-popover-open');
@@ -614,6 +741,148 @@
}
}
function startHoverPreviewLoop() {
if (!currentCardState || !currentCardState.hoverPreviewRunning || !currentCardState.scrubberData) {
hoverPreviewRafId = null;
return;
}
const state = currentCardState;
const data = state.scrubberData;
const container = state.cardImageContainer;
if (!container || typeof ApiClient === 'undefined') {
state.hoverPreviewRunning = false;
hoverPreviewRafId = null;
return;
}
const runTimeTicks = data.runTimeTicks || 0;
if (runTimeTicks <= 0) {
state.hoverPreviewRunning = false;
hoverPreviewRafId = null;
return;
}
// Base trickplay interval (ms) comes from server metadata.
const baseIntervalMs = data.trickplayInfo && data.trickplayInfo.Interval ? data.trickplayInfo.Interval : 10000;
// Optional override from config for hover preview speed (ms between frames).
const configuredIntervalMs = getHoverPreviewFrameIntervalMs();
// Draw initial frame immediately at current positionTicks using original trickplay aspect ratio.
const renderFrame = () => {
if (!currentCardState || !currentCardState.hoverPreviewRunning || currentCardState.card !== state.card) {
return;
}
const rect = container.getBoundingClientRect();
const cw = rect.width || 0;
// Maintain trickplay aspect ratio (16:9 by default) regardless of poster aspect.
const ch = Math.round(cw / PREVIEW_ASPECT) || 0;
// Create or reuse a clipped hover preview box inside the card image.
let box = container.querySelector('.kefin-hover-preview-box');
if (!box) {
box = document.createElement('div');
box.className = 'kefin-hover-preview-box';
const frame = document.createElement('div');
frame.className = 'kefin-hover-preview-frame';
box.appendChild(frame);
box._frame = frame;
container.appendChild(box);
}
const frameEl = box._frame || box.firstElementChild || box;
box.style.width = cw + 'px';
box.style.height = ch + 'px';
const positionTicks = state.hoverPreviewPositionTicks || 0;
const style = getTrickplayStyle(
ApiClient,
state.itemId,
data.trickplayInfo,
data.mediaSourceId,
positionTicks,
cw,
ch
);
frameEl.style.backgroundImage = style.backgroundImage;
frameEl.style.backgroundPositionX = style.backgroundPositionX;
frameEl.style.backgroundPositionY = style.backgroundPositionY;
frameEl.style.backgroundSize = style.backgroundSize != null ? style.backgroundSize : 'auto';
};
// Initial draw
renderFrame();
// Clear any previous interval to avoid duplicates
if (hoverPreviewIntervalId != null) {
clearInterval(hoverPreviewIntervalId);
hoverPreviewIntervalId = null;
}
// Advance exactly one tile per tick, using the base trickplay interval for distance in ticks
// so we always land on real trickplay frame boundaries, even if the visual speed is overridden.
hoverPreviewIntervalId = setInterval(() => {
if (!currentCardState || !currentCardState.hoverPreviewRunning || currentCardState.card !== state.card) {
clearInterval(hoverPreviewIntervalId);
hoverPreviewIntervalId = null;
return;
}
const advanceTicks = baseIntervalMs * TICKS_PER_MS;
const currentTicks = (state.hoverPreviewPositionTicks || 0) + advanceTicks;
const wrappedTicks = currentTicks % runTimeTicks;
state.hoverPreviewPositionTicks = wrappedTicks;
renderFrame();
}, configuredIntervalMs);
}
async function ensureScrubberDataForHover(state) {
if (!state) return;
if (state.scrubberData) return;
const data = await getScrubberData(state.itemId);
if (!data) {
if (state.overlay) {
state.overlay.setAttribute('data-no-trickplay', 'true');
state.overlay.classList.remove('is-visible', 'is-zone-active');
}
return;
}
state.scrubberData = data;
}
async function maybeStartHoverPreview() {
if (!currentCardState || !isHoverPreviewEnabled()) return;
const state = currentCardState;
if (state.hoverPreviewRunning || state.hoverPreviewTimer) return;
const timeoutMs = getHoverPreviewTimeoutMs();
const timeout = timeoutMs < 0 ? 0 : timeoutMs;
state.hoverPreviewTimer = setTimeout(async () => {
state.hoverPreviewTimer = null;
if (!currentCardState || currentCardState.card !== state.card) return;
await ensureScrubberDataForHover(state);
if (!currentCardState || currentCardState.card !== state.card) return;
state.hoverPreviewRunning = true;
state.hoverPreviewLastTs = performance.now();
// Start from card's data-positionticks when available, otherwise from 0.
const posAttr = state.card && state.card.getAttribute
? state.card.getAttribute('data-positionticks')
: null;
const startTicks = posAttr && !Number.isNaN(Number(posAttr))
? Number(posAttr)
: 0;
state.hoverPreviewPositionTicks = startTicks;
state.hoverPreviewFrameAccumMs = 0;
startHoverPreviewLoop();
}, timeout);
}
function attachCard(ctx) {
if (!ctx) return;
const overlay = getOrCreateScrubberOverlay(ctx.cardImageContainer);
@@ -626,9 +895,17 @@
activationTimer: null,
scrubberData: null,
lastClientX: 0,
lastClientY: 0
lastClientY: 0,
hoverPreviewTimer: null,
hoverPreviewRunning: false,
hoverPreviewLastTs: 0,
hoverPreviewPositionTicks: 0,
hoverPreviewFrameAccumMs: 0
};
overlay.classList.add('is-visible');
if (isHoverPreviewEnabled()) {
maybeStartHoverPreview();
}
}
function getDelegationRoot() {
@@ -709,12 +986,17 @@
const overlay = state.overlay;
if (inZone) {
// When user actively scrubs, stop passive hover preview
if (isHoverPreviewEnabled()) {
stopHoverPreview();
}
if (overlay.hasAttribute('data-no-trickplay')) return;
if (!state.activationTimer) {
const delayMs = getScrubActivationDelayMs();
state.activationTimer = setTimeout(() => {
state.activationTimer = null;
activateScrubberForCurrentCard();
}, SCRUB_ACTIVATION_DELAY_MS);
}, delayMs);
}
scheduleUIUpdate();
} else {
@@ -723,6 +1005,11 @@
if (overlay._progressFill) overlay._progressFill.style.width = '0%';
}
if (!overlay._popoverOpen) clearActivationTimer();
// Outside scrub zone but still on card - ensure hover preview timer is running
if (isHoverPreviewEnabled() && !state.hoverPreviewRunning && !state.hoverPreviewTimer) {
maybeStartHoverPreview();
}
}
}