mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Add Thumbnail Scrubber configuration UI and Preview on Hover feature
This commit is contained in:
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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user