From e305f807100a6744bd1840e53b71df0362ef4fa9 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Mon, 9 Feb 2026 00:12:24 -0500 Subject: [PATCH] New feature: Thumbnail Scrubbing for Movies and Episodes ala Youtube --- scripts/thumbnailScrubber.js | 829 +++++++++++++++++++++++++++++++++++ 1 file changed, 829 insertions(+) create mode 100644 scripts/thumbnailScrubber.js diff --git a/scripts/thumbnailScrubber.js b/scripts/thumbnailScrubber.js new file mode 100644 index 0000000..5cab771 --- /dev/null +++ b/scripts/thumbnailScrubber.js @@ -0,0 +1,829 @@ +// Thumbnail Scrubber +// YouTube-style scrubber: on card hover show a progress bar at the bottom; near the bar show timestamp and trickplay thumb above cursor. + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks ThumbnailScrubber]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks ThumbnailScrubber]', ...args); + const ERR = (...args) => console.error('[KefinTweaks ThumbnailScrubber]', ...args); + + const SCRUB_ZONE_HEIGHT_PX = 40; + const SCRUB_ACTIVATION_DELAY_MS = 300; + const PROGRESS_BAR_WIDTH_PERCENT = 95; + + const VIDEO_TYPES = ['Movie', 'Episode']; + + /** Ticks are 100-nanosecond units; 10_000 ticks = 1 ms */ + const TICKS_PER_MS = 10_000; + + const PREVIEW_WIDTH_PERCENT = 0.25; + const PREVIEW_WIDTH_MIN_PX = 150; + const PREVIEW_ASPECT = 16 / 9; + + /** Trickplay data by item id */ + const trickPlayMap = new Map(); + + /** + * Single module-level state for the card currently under the cursor (event delegation). + * { card, cardImageContainer, itemId, overlay, activationTimer, scrubberData, lastClientX, lastClientY } + */ + let currentCardState = null; + let rafId = null; + + if (!document.getElementById('kefin-scrubber-styles')) { + const style = document.createElement('style'); + style.id = 'kefin-scrubber-styles'; + style.textContent = ` + .kefin-scrubber-overlay { + position: absolute; inset: 0; z-index: 15; + display: flex; flex-direction: column; justify-content: flex-end; + pointer-events: none; + opacity: 0; transition: opacity 0.15s ease; + } + .kefin-scrubber-overlay.is-visible { opacity: 1; } + .kefin-scrubber-overlay.is-zone-active { + pointer-events: auto; + } + .kefin-scrubber-progress-wrap { + width: 100%; + display: flex; justify-content: center; + transition: transform 0.2s ease, padding 0.2s ease; + position: relative; + z-index: 20; + } + .kefin-scrubber-overlay.is-zone-active .kefin-scrubber-progress-wrap { + transform: translateY(-8px); + padding-bottom: 8px; + pointer-events: auto; + } + .kefin-scrubber-progress-track { + height: 4px; border-radius: 2px; + background: rgba(255,255,255,0.3); + width: 100%; + max-width: 95%; + overflow: hidden; + } + .kefin-scrubber-progress-fill { + height: 100%; border-radius: 2px; + background: rgba(255,255,255,0.9); + width: 0%; transition: none; + } + .kefin-scrubber-preview { + position: absolute; + left: 0; + bottom: 20px; + padding: 0 0 6px; + display: none; flex-direction: column; align-items: center; + pointer-events: none; + z-index: 10; + transition: transform 0.2s ease; + } + .kefin-scrubber-overlay.is-zone-active .kefin-scrubber-preview { + display: flex; + } + .kefin-scrubber-overlay.is-popover-open .kefin-scrubber-preview { + transform: translateY(-80px); + } + .kefin-scrubber-popover { + position: absolute; left: 50%; bottom: 100%; + transform: translate(-50%, 8px); + min-width: 160px; + background: rgba(28,28,28,0.98); + border: 1px solid rgba(255,255,255,0.2); + border-radius: 6px; + box-shadow: 0 4px 20px rgba(0,0,0,0.5); + padding: 4px 0; + z-index: 25; + pointer-events: auto; + opacity: 0; + transition: transform 0.2s ease, opacity 0.2s ease; + } + .kefin-scrubber-overlay.is-popover-open .kefin-scrubber-popover { + opacity: 1; + transform: translate(-50%, -4px); + } + .kefin-scrubber-chapterThumbWrapper { + overflow: hidden; border-radius: 4px; + box-shadow: 0 2px 12px rgba(0,0,0,0.5); + background: #000; + background-repeat: no-repeat; + border: 1px solid rgba(255, 255, 255, 0.85); + } + .kefin-scrubber-timestamp { + margin-top: 4px; padding: 2px 8px; + background: rgba(0,0,0,0.85); border-radius: 4px; + color: #fff; font-size: 13px; font-weight: 500; + } + .card:has(.kefin-scrubber-overlay.is-visible.is-zone-active) .cardOverlayButton-br { + bottom: 30px; + } + .card:has(.kefin-scrubber-overlay.is-visible) .cardOverlayButton-br { + bottom: 10px; + } + .card:has(.kefin-scrubber-overlay) .cardOverlayButton-br { + transition: bottom 200ms ease; + } + .kefin-scrubber-popover-option { + display: block; + width: 100%; + padding: 8px 14px; + border: none; + background: none; + color: rgba(255,255,255,0.9); + font-size: 13px; + text-align: left; + cursor: pointer; + } + .kefin-scrubber-popover-option:hover { + background: rgba(255,255,255,0.1); + } + .kefin-scrubber-overlay[data-no-trickplay] { + display: none !important; + } + `; + document.head.appendChild(style); + } + + /** + * Format positionTicks as running time (MM:SS or HH:MM:SS). + * Uses Jellyfin's datetime.getDisplayRunningTime if available. + */ + function formatRunningTime(positionTicks) { + if (typeof datetime !== 'undefined' && typeof datetime.getDisplayRunningTime === 'function') { + return datetime.getDisplayRunningTime(positionTicks); + } + const totalMs = Math.floor(positionTicks / TICKS_PER_MS); + const totalSeconds = Math.floor(totalMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${minutes}:${String(seconds).padStart(2, '0')}`; + } + + /** + * Check if item is video and has runtime (we need RunTimeTicks for scrub). + * Returns { eligible: boolean, reason?: string } for logging. + */ + function checkEligibleCard(card) { + let id = card.getAttribute('data-id'); + let type = card.getAttribute('data-type'); + + if (!id) { + if (card.parentElement.classList.contains('detailImageContainer')) { + // If the card is a .detailImageContainer, we need to get the itemId from the page's Play button + const detailPagePrimaryContainer = card.closest('.detailPagePrimaryContainer'); + if (detailPagePrimaryContainer) { + const playStateButton = detailPagePrimaryContainer.querySelector('.btnPlaystate'); + if (playStateButton) { + id = playStateButton.getAttribute('data-id'); + type = playStateButton.getAttribute('data-type'); + } + } + } + } + if (!id) return { eligible: false, reason: 'no data-id' }; + const isVideoType = VIDEO_TYPES.includes(type); + if (!isVideoType) { + return { eligible: false, reason: `not video (type=${type})` }; + } + return { eligible: true }; + } + + function isEligibleCard(card) { + return checkEligibleCard(card).eligible; + } + + /** + * Resolve the scrubber "card" context from an element (e.target or e.relatedTarget). + * Returns { card, cardImageContainer, itemId } if the element is inside an eligible card within + * .itemsContainer or .detailImageContainer; otherwise null. + */ + function resolveCardFromElement(element) { + if (!element || !element.closest) return null; + const card = element.closest('.card'); + if (!card) return null; + const container = card.closest('.itemsContainer') || card.closest('.detailImageContainer'); + if (!container) return null; + if (!isEligibleCard(card)) return null; + const cardImageContainer = card.querySelector('.cardImageContainer'); + if (!cardImageContainer) return null; + let itemId = card.getAttribute('data-id'); + if (!itemId && card.parentElement && card.parentElement.classList.contains('detailImageContainer')) { + const detailPagePrimaryContainer = card.closest('.detailPagePrimaryContainer'); + if (detailPagePrimaryContainer) { + const playStateButton = detailPagePrimaryContainer.querySelector('.btnPlaystate'); + if (playStateButton) itemId = playStateButton.getAttribute('data-id'); + } + } + if (!itemId) return null; + return { card, cardImageContainer, itemId }; + } + + /** + * Extract trickplay info object from item.Trickplay. + * Item.Trickplay can be the info object directly, or nested e.g. Trickplay[mediaSourceId][width] = { Width, Height, Interval, ... }. + */ + function getTrickplayInfoFromItem(item) { + const raw = item.Trickplay; + if (!raw || typeof raw !== 'object') return null; + // Already a flat info object (has Width/Interval etc.) + if (typeof raw.Width !== 'undefined' && typeof raw.Interval !== 'undefined') { + return raw; + } + // Nested: first key (e.g. mediaSourceId) -> second key (e.g. width) -> info + const firstKey = Object.keys(raw)[0]; + if (!firstKey) return null; + const inner = raw[firstKey]; + if (!inner || typeof inner !== 'object') return null; + if (typeof inner.Width !== 'undefined' && typeof inner.Interval !== 'undefined') { + return inner; + } + const secondKey = Object.keys(inner)[0]; + if (!secondKey) return null; + const info = inner[secondKey]; + if (info && typeof info.Width !== 'undefined' && typeof info.Interval !== 'undefined') { + return info; + } + return null; + } + + /** + * Fetch item to get RunTimeTicks, MediaSourceId, and Trickplay. + */ + async function fetchItem(itemId) { + if (typeof ApiClient === 'undefined' || !ApiClient.getItem) { + LOG('fetchItem: ApiClient unavailable'); + return null; + } + const userId = ApiClient.getCurrentUserId(); + if (!userId) { + LOG('fetchItem: no userId', itemId); + return null; + } + try { + LOG('fetchItem: requesting', itemId); + const item = await ApiClient.getItem(userId, itemId); + LOG('fetchItem: got item', itemId, item ? (item.RunTimeTicks ? 'has RunTimeTicks' : 'no RunTimeTicks') : 'null'); + return item; + } catch (e) { + WARN('fetchItem failed', itemId, e); + return null; + } + } + + /** + * Get or populate scrubber data for an item. Uses only the item response (RunTimeTicks, MediaSourceId, Trickplay). + */ + async function getScrubberData(itemId) { + if (trickPlayMap.has(itemId)) { + LOG('getScrubberData: cache hit', itemId); + return trickPlayMap.get(itemId); + } + LOG('getScrubberData: cache miss, fetching item', itemId); + const item = await fetchItem(itemId); + if (!item || !item.RunTimeTicks || item.RunTimeTicks <= 0) { + LOG('getScrubberData: no item or RunTimeTicks', itemId); + return null; + } + const mediaSourceId = item.MediaSourceId || item.Id; + const trickplayInfo = getTrickplayInfoFromItem(item); + if (!trickplayInfo) { + LOG('getScrubberData: no Trickplay on item', itemId); + return null; + } + LOG('getScrubberData: Trickplay from item', itemId, 'mediaSourceId=', mediaSourceId, 'interval=', trickplayInfo.Interval); + const data = { + runTimeTicks: item.RunTimeTicks, + trickplayInfo, + mediaSourceId, + item + }; + trickPlayMap.set(itemId, data); + LOG('getScrubberData: cached', itemId, 'runTimeTicks=', data.runTimeTicks); + return data; + } + + /** + * Compute trickplay tile index and background position from positionTicks. + * Matches Jellyfin's updateTrickplayBubbleHtml tile math. + * When containerWidth and containerHeight are provided and > 0, returns scaled backgroundSize and position + * so one tile exactly fills the container; otherwise returns position in source pixels (backgroundSize left to call site). + */ + function getTrickplayStyle(apiClient, itemId, trickplayInfo, mediaSourceId, positionTicks, containerWidth, containerHeight) { + const interval = trickplayInfo.Interval || 10000; + const width = trickplayInfo.Width || 320; + const height = trickplayInfo.Height || 180; + const tileWidth = trickplayInfo.TileWidth || 10; + const tileHeight = trickplayInfo.TileHeight || 10; + + const currentTimeMs = positionTicks / TICKS_PER_MS; + const currentTile = Math.floor(currentTimeMs / interval); + const tileSize = tileWidth * tileHeight; + const index = Math.floor(currentTile / tileSize); + const tileOffset = currentTile % tileSize; + const tileOffsetX = tileOffset % tileWidth; + const tileOffsetY = Math.floor(tileOffset / tileWidth); + + const imgSrc = apiClient.getUrl('Videos/' + itemId + '/Trickplay/' + width + '/' + index + '.jpg', { + ApiKey: apiClient.accessToken(), + MediaSourceId: mediaSourceId + }); + + const useScaled = containerWidth > 0 && containerHeight > 0; + if (useScaled) { + const backgroundSize = (tileWidth * containerWidth) + 'px ' + (tileHeight * containerHeight) + 'px'; + const backgroundPositionX = -(tileOffsetX * containerWidth) + 'px'; + const backgroundPositionY = -(tileOffsetY * containerHeight) + 'px'; + return { + backgroundImage: `url('${imgSrc}')`, + backgroundPositionX, + backgroundPositionY, + backgroundSize, + width: width + 'px', + height: height + 'px' + }; + } + + const offsetX = -(tileOffsetX * width); + const offsetY = -(tileOffsetY * height); + return { + backgroundImage: `url('${imgSrc}')`, + backgroundPositionX: offsetX + 'px', + backgroundPositionY: offsetY + 'px', + width: width + 'px', + height: height + 'px' + }; + } + + /** + * Estimate positionTicks from mouse X as fraction of card width and item runTimeTicks. + */ + function positionTicksFromCard(card, clientX, runTimeTicks) { + const rect = card.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return Math.floor(fraction * runTimeTicks); + } + + /** + * True if pointer is in the bottom SCRUB_ZONE_HEIGHT_PX of the given element (e.g. cardImageContainer). + * Only the area up to the element's bottom edge is considered; below that is not in zone. + * Use cardImageContainer so the zone does not extend into card content below the image. + */ + function isInScrubZone(containerElement, clientY) { + if (!containerElement) return false; + const rect = containerElement.getBoundingClientRect(); + return clientY >= rect.bottom - SCRUB_ZONE_HEIGHT_PX && clientY <= rect.bottom; + } + + /** + * Create or return existing scrubber overlay as a sibling of .cardOverlayContainer (so it appears above it). + * Structure: overlay > progressWrap > progressTrack > progressFill; preview > chapterThumbWrapper + timestamp. + */ + function getOrCreateScrubberOverlay(cardImageContainer) { + const parent = cardImageContainer.parentNode; + if (!parent) return null; + + let overlay = parent.querySelector('.kefin-scrubber-overlay'); + if (overlay) return overlay; + + overlay = document.createElement('div'); + overlay.className = 'kefin-scrubber-overlay'; + + const progressWrap = document.createElement('div'); + progressWrap.className = 'kefin-scrubber-progress-wrap'; + + const progressTrack = document.createElement('div'); + progressTrack.className = 'kefin-scrubber-progress-track'; + + const progressFill = document.createElement('div'); + progressFill.className = 'kefin-scrubber-progress-fill'; + + progressTrack.appendChild(progressFill); + progressWrap.appendChild(progressTrack); + overlay.appendChild(progressWrap); + + const popover = document.createElement('div'); + popover.className = 'kefin-scrubber-popover'; + popover.style.display = 'none'; + const optPlay = document.createElement('button'); + optPlay.type = 'button'; + optPlay.className = 'kefin-scrubber-popover-option'; + optPlay.textContent = 'Play From Here'; + const optResume = document.createElement('button'); + optResume.type = 'button'; + optResume.className = 'kefin-scrubber-popover-option'; + optResume.textContent = 'Set Resume Position'; + popover.appendChild(optPlay); + popover.appendChild(optResume); + progressWrap.appendChild(popover); + + overlay._progressWrap = progressWrap; + overlay._popover = popover; + + const preview = document.createElement('div'); + preview.className = 'kefin-scrubber-preview'; + + const chapterThumbWrapper = document.createElement('div'); + chapterThumbWrapper.className = 'kefin-scrubber-chapterThumbWrapper'; + + const timestampEl = document.createElement('div'); + timestampEl.className = 'kefin-scrubber-timestamp'; + + preview.appendChild(chapterThumbWrapper); + preview.appendChild(timestampEl); + overlay.appendChild(preview); + + const cardOverlayContainer = parent.querySelector('.cardOverlayContainer'); + if (cardOverlayContainer) { + parent.insertBefore(overlay, cardOverlayContainer); + } else { + parent.appendChild(overlay); + } +/* if (!parent.style.position || parent.style.position === 'static') { + parent.style.position = 'relative'; + } */ + + overlay._progressTrack = progressTrack; + overlay._progressFill = progressFill; + overlay._preview = preview; + overlay._chapterThumbWrapper = chapterThumbWrapper; + overlay._timestampEl = timestampEl; + return overlay; + } + + /** + * Position preview (thumb + timestamp) so thumb is centered above cursor but clamped to card width. + */ + function positionPreview(previewEl, thumbWidth, cardRect, clientX) { + const cardPadding = 10; + const half = thumbWidth / 2; + let left = clientX - cardRect.left - half; + left = Math.max(cardPadding, Math.min(left, cardRect.width - thumbWidth - (cardPadding * 2))); + previewEl.style.left = left + 'px'; + previewEl.style.right = 'auto'; + previewEl.style.width = thumbWidth + 'px'; + } + + /** + * Update or create the innerCardFooter progress bar on the card image. + * fraction is 0..1 of total runtime. + */ + function updateCardResumeFooter(card, fraction) { + if (!card) return; + const cardImageContainer = card.querySelector('.cardImageContainer'); + if (!cardImageContainer) return; + + const clamped = Math.max(0, Math.min(1, fraction || 0)); + const widthPercent = (clamped * 100).toFixed(4) + '%'; + + let footer = cardImageContainer.querySelector('.innerCardFooter'); + if (!footer) { + footer = document.createElement('div'); + footer.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear'; + + const bar = document.createElement('div'); + bar.className = 'itemProgressBar'; + + const barFg = document.createElement('div'); + barFg.className = 'itemProgressBarForeground'; + + bar.appendChild(barFg); + footer.appendChild(bar); + cardImageContainer.appendChild(footer); + } + + const barFg = footer.querySelector('.itemProgressBarForeground'); + if (barFg) { + barFg.style.width = widthPercent; + } + } + + /** Fraction 0..1 from clientX relative to the visible progress track (for time/thumb). */ + function getScrubFraction(card, clientX) { + if (!card) return 0; + const rect = card.getBoundingClientRect(); + const barWidth = rect.width * (PROGRESS_BAR_WIDTH_PERCENT / 100); + const barLeft = rect.left + (rect.width - barWidth) / 2; + return Math.max(0, Math.min(1, (clientX - barLeft) / barWidth)); + } + + /** Fill the progress bar visually up to the cursor using the track's actual rect. */ + function getProgressFillFraction(overlay, clientX) { + if (!overlay || !overlay._progressTrack) return 0; + const trackRect = overlay._progressTrack.getBoundingClientRect(); + if (trackRect.width <= 0) return 0; + return Math.max(0, Math.min(1, (clientX - trackRect.left) / trackRect.width)); + } + + /** + * Update scrubber UI (progress fill, timestamp, thumb position, trickplay image). + * When popover is open, uses frozen _popoverClientX/_popoverClientY so position stays fixed. + * state: { card, cardImageContainer, itemId, overlay, scrubberData } + */ + function updateUI(state, clientX, clientY) { + if (!state || !state.overlay) return; + const { card, cardImageContainer, itemId, overlay, scrubberData } = state; + + if (overlay._popoverOpen && overlay._popoverClientX != null) { + clientX = overlay._popoverClientX; + clientY = overlay._popoverClientY; + } + + const rect = card.getBoundingClientRect(); + const fraction = getScrubFraction(card, clientX); + + const thumbWidth = Math.max(PREVIEW_WIDTH_MIN_PX, Math.round(rect.width * PREVIEW_WIDTH_PERCENT)); + const thumbHeight = Math.round(thumbWidth / PREVIEW_ASPECT); + + overlay._chapterThumbWrapper.style.width = thumbWidth + 'px'; + overlay._chapterThumbWrapper.style.height = thumbHeight + 'px'; + + const fillFraction = getProgressFillFraction(overlay, clientX); + overlay._progressFill.style.width = (fillFraction * 100) + '%'; + + const runTimeTicks = scrubberData ? scrubberData.runTimeTicks : 0; + const positionTicks = Math.floor(fraction * runTimeTicks); + + overlay._timestampEl.textContent = runTimeTicks ? formatRunningTime(positionTicks) : '0:00'; + + positionPreview(overlay._preview, thumbWidth, rect, clientX); + + if (scrubberData && typeof ApiClient !== 'undefined') { + const cw = thumbWidth; + const ch = thumbHeight; + const style = getTrickplayStyle( + ApiClient, + itemId, + scrubberData.trickplayInfo, + scrubberData.mediaSourceId, + positionTicks, + cw, + ch + ); + overlay._chapterThumbWrapper.style.backgroundImage = style.backgroundImage; + overlay._chapterThumbWrapper.style.backgroundPositionX = style.backgroundPositionX; + overlay._chapterThumbWrapper.style.backgroundPositionY = style.backgroundPositionY; + overlay._chapterThumbWrapper.style.backgroundSize = style.backgroundSize != null ? style.backgroundSize : 'auto'; + } + } + + function clearActivationTimer() { + if (currentCardState && currentCardState.activationTimer) { + clearTimeout(currentCardState.activationTimer); + currentCardState.activationTimer = null; + } + } + + function hidePopoverState() { + if (currentCardState && currentCardState.overlay && currentCardState.overlay._popover) { + const overlay = currentCardState.overlay; + overlay._popover.style.display = 'none'; + overlay._popoverOpen = false; + overlay.classList.remove('is-popover-open'); + overlay._popoverClientX = null; + overlay._popoverClientY = null; + } + } + + function collapseZone() { + if (currentCardState && currentCardState.overlay) { + const overlay = currentCardState.overlay; + overlay.classList.remove('is-zone-active'); + if (overlay._progressFill) overlay._progressFill.style.width = '0%'; + } + } + + function teardown() { + if (!currentCardState) return; + clearActivationTimer(); + if (currentCardState.overlay) { + const overlay = currentCardState.overlay; + overlay.classList.remove('is-visible', 'is-zone-active', 'is-popover-open'); + if (overlay._progressFill) overlay._progressFill.style.width = '0%'; + overlay._popoverOpen = false; + if (overlay._popover) overlay._popover.style.display = 'none'; + } + currentCardState = null; + if (rafId != null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + function attachCard(ctx) { + if (!ctx) return; + const overlay = getOrCreateScrubberOverlay(ctx.cardImageContainer); + if (overlay.hasAttribute('data-no-trickplay')) return; + currentCardState = { + card: ctx.card, + cardImageContainer: ctx.cardImageContainer, + itemId: ctx.itemId, + overlay: overlay, + activationTimer: null, + scrubberData: null, + lastClientX: 0, + lastClientY: 0 + }; + overlay.classList.add('is-visible'); + } + + function getDelegationRoot() { + return document.getElementById('reactRoot') || document.body; + } + + function updateUIFromRAF() { + rafId = null; + if (currentCardState) { + updateUI(currentCardState, currentCardState.lastClientX, currentCardState.lastClientY); + } + } + + function scheduleUIUpdate() { + if (rafId != null) return; + rafId = requestAnimationFrame(updateUIFromRAF); + } + + async function activateScrubberForCurrentCard() { + if (!currentCardState) return; + const itemId = currentCardState.itemId; + const overlay = currentCardState.overlay; + LOG('activateScrubber: start', itemId); + const data = await getScrubberData(itemId); + if (!data) { + LOG('activateScrubber: no data, abort', itemId); + overlay.setAttribute('data-no-trickplay', 'true'); + overlay.classList.remove('is-visible', 'is-zone-active'); + return; + } + overlay.classList.add('is-zone-active'); + currentCardState.scrubberData = data; + updateUI(currentCardState, currentCardState.lastClientX, currentCardState.lastClientY); + } + + function showPopoverState(overlay, positionTicks, mediaSourceId, clientX, clientY) { + if (!currentCardState) return; + overlay._popover._positionTicks = positionTicks; + overlay._popover._itemId = currentCardState.itemId; + overlay._popover._mediaSourceId = mediaSourceId; + overlay._popoverClientX = clientX; + overlay._popoverClientY = clientY; + overlay._popover.style.display = 'block'; + overlay._popoverOpen = true; + overlay.classList.add('is-popover-open'); + } + + function onDelegatedMouseOver(e) { + const ctx = resolveCardFromElement(e.target); + if (!ctx) return; + if (currentCardState && currentCardState.card === ctx.card) return; + teardown(); + attachCard(ctx); + } + + function onDelegatedMouseMove(e) { + const ctx = resolveCardFromElement(e.target); + if (!ctx) { + if (currentCardState) teardown(); + return; + } + if (currentCardState && ctx.card !== currentCardState.card) { + teardown(); + attachCard(ctx); + } + if (!currentCardState) return; + const state = currentCardState; + + // Do not activate scrubber when hovering overlay action button (play, etc.) + if (e.target && e.target.closest && e.target.closest('.cardOverlayButton-br')) { + clearActivationTimer(); + return; + } + + state.lastClientX = e.clientX; + state.lastClientY = e.clientY; + const inZone = isInScrubZone(state.cardImageContainer, e.clientY); + const overlay = state.overlay; + + if (inZone) { + if (overlay.hasAttribute('data-no-trickplay')) return; + if (!state.activationTimer) { + state.activationTimer = setTimeout(() => { + state.activationTimer = null; + activateScrubberForCurrentCard(); + }, SCRUB_ACTIVATION_DELAY_MS); + } + scheduleUIUpdate(); + } else { + if (!overlay._popoverOpen) { + overlay.classList.remove('is-zone-active'); + if (overlay._progressFill) overlay._progressFill.style.width = '0%'; + } + if (!overlay._popoverOpen) clearActivationTimer(); + } + } + + function onDelegatedMouseOut(e) { + const related = resolveCardFromElement(e.relatedTarget); + if (related) return; + teardown(); + } + + function onDelegatedClick(e) { + const ctx = resolveCardFromElement(e.target); + if (!ctx || !currentCardState || ctx.card !== currentCardState.card) return; + const overlay = currentCardState.overlay; + if (!overlay) return; + + if (overlay._popoverOpen && overlay._popover && !overlay._popover.contains(e.target) && currentCardState.card.contains(e.target)) { + hidePopoverState(); + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (!overlay.classList.contains('is-zone-active')) return; + if (overlay._popover && overlay._popover.contains(e.target)) return; + if (!overlay.contains(e.target)) return; + if (!isInScrubZone(currentCardState.cardImageContainer, e.clientY)) return; + e.preventDefault(); + e.stopPropagation(); + + (async () => { + const itemId = currentCardState.itemId; + const clickCard = currentCardState.card; + let data = currentCardState.scrubberData; + if (!data) { + data = await getScrubberData(itemId); + if (!data) { + overlay.setAttribute('data-no-trickplay', 'true'); + overlay.classList.remove('is-visible', 'is-zone-active'); + return; + } + currentCardState.scrubberData = data; + } + const fraction = getScrubFraction(currentCardState.card, e.clientX); + const positionTicks = Math.floor(fraction * data.runTimeTicks); + showPopoverState(overlay, positionTicks, data.mediaSourceId, e.clientX, e.clientY); + + const pop = overlay._popover; + + function playFromHere() { + if (window.apiHelper && typeof window.apiHelper.playItem === 'function') { + window.apiHelper.playItem(pop._itemId, { + startPositionTicks: pop._positionTicks, + mediaSourceId: pop._mediaSourceId || undefined + }).catch(err => WARN('Play From Here failed', err)); + } + } + + function setResumePosition() { + const userId = typeof ApiClient !== 'undefined' && ApiClient.getCurrentUserId ? ApiClient.getCurrentUserId() : null; + if (!userId || !ApiClient || !ApiClient.serverAddress) return; + const url = `${ApiClient.serverAddress()}/Users/${userId}/Items/${pop._itemId}/UserData`; + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Emby-Token': ApiClient.accessToken() + }, + body: JSON.stringify({ PlaybackPositionTicks: pop._positionTicks }) + }).then(r => { + if (!r.ok) throw new Error(r.statusText); + if (!data || !data.runTimeTicks) return; + const fraction = pop._positionTicks / data.runTimeTicks; + updateCardResumeFooter(clickCard, fraction); + }).catch(err => WARN('Set Resume Position failed', err)); + } + + const opts = pop.querySelectorAll('.kefin-scrubber-popover-option'); + const optClick = (ev, action) => { + ev.preventDefault(); + ev.stopPropagation(); + hidePopoverState(); + collapseZone(); + action(); + }; + opts[0].onclick = (ev) => optClick(ev, playFromHere); + opts[1].onclick = (ev) => optClick(ev, setResumePosition); + })(); + } + + function init() { + const root = getDelegationRoot(); + root.addEventListener('mouseover', onDelegatedMouseOver, true); + root.addEventListener('mousemove', onDelegatedMouseMove, true); + root.addEventListener('mouseout', onDelegatedMouseOut, true); + root.addEventListener('click', onDelegatedClick, true); + LOG('Initialized (event delegation)'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();