diff --git a/configuration/thumbnailScrubber-configuration.js b/configuration/thumbnailScrubber-configuration.js new file mode 100644 index 0000000..e44a616 --- /dev/null +++ b/configuration/thumbnailScrubber-configuration.js @@ -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 ` +
+
+
+
Preview On Hover
+
+ 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. +
+ +
+
+ +
+
+
Delay Before Previewing On Hover (ms)
+
+ How long the cursor must stay over a card before the passive preview starts. + Set to 0 to start immediately when hovering a card. +
+ +
+
+ +
+
+
Hover Preview Speed (ms between frames)
+
+ Interval in milliseconds between each trickplay frame during hover preview. + Leave at 0 to use the default trickplay interval from the server. +
+ +
+
+ +
+
+
Delay Before Activating Scrubbing (ms)
+
+ How long the cursor must stay in the scrub zone at the bottom of the card before + the scrubber UI activates. +
+ +
+
+
+ `; + } + + /** + * 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 = ` + + + `; + + // 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'); + +})(); + diff --git a/injector.js b/injector.js index d50c966..60015a2 100644 --- a/injector.js +++ b/injector.js @@ -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) { diff --git a/scripts/thumbnailScrubber.js b/scripts/thumbnailScrubber.js index 5cab771..790a08e 100644 --- a/scripts/thumbnailScrubber.js +++ b/scripts/thumbnailScrubber.js @@ -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(); + } } }