// 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; // 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']; /** When true (default), hover preview uses video stream instead of trickplay images. */ const USE_VIDEO_STREAM_DEFAULT = true; /** Ms to wait for static stream start before fallback (0 = timeout disabled). */ const FALLBACK_AFTER_MS_DEFAULT = 500; /** 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; let hoverPreviewRafId = null; let hoverPreviewIntervalId = 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: 3px 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; } /* 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; } /* Video stream preview: sibling before cardImageContainer, overlays image area */ .kefin-hover-preview-sibling { position: absolute; overflow: hidden; pointer-events: none; display: flex; align-items: center; justify-content: center; } .kefin-hover-preview-sibling .kefin-hover-preview-box { position: relative; left: auto; right: auto; top: auto; transform: none; margin: 0; } .kefin-hover-preview-sibling video.kefin-hover-preview-video { width: 100%; height: 100%; object-fit: contain; pointer-events: none; display: block; } .cardImageContainer .kefin-hover-preview-frame { width: 100%; height: 100%; background-repeat: no-repeat; } .cardImageContainer .kefin-hover-preview-box video.kefin-hover-preview-video { width: 100%; height: 100%; object-fit: contain; pointer-events: none; display: block; } .cardScalable:has(.kefin-hover-preview-sibling) .cardOverlayContainer, .cardScalable:has(.kefin-hover-preview-frame[style]:not([style=""])) .cardOverlayContainer { background: none !important; pointer-events: none; transition: background 0.3s ease-in-out !important; } /* Same-duration top+left (+ transform/margin) so the play button moves diagonally, not axis-by-axis (top/margin → bottom/left produces an L-shaped path). */ .cardOverlayButton[data-action="resume"].cardOverlayFab-primary, .cardOverlayButton[data-action="resume"] { transition: top 0.28s ease, left 0.28s ease, right 0.28s ease, bottom 0.28s ease, margin 0.28s ease, transform 0.28s ease, opacity 0.28s ease; } .cardScalable:has(.kefin-hover-preview-sibling) .cardOverlayButton[data-action="resume"], .cardScalable:has(.kefin-hover-preview-frame[style]:not([style=""])) .cardOverlayButton[data-action="resume"] { top: calc(100% - 1.3em) !important; left: 1.3em !important; right: auto !important; bottom: auto !important; margin: 0 !important; transform: translateY(-100%); opacity: 1; visibility: visible; pointer-events: auto; display: inline-flex; } `; document.head.appendChild(style); } /** * Read thumbnail scrubber configuration from global config. * Returns { previewOnHover, hoverTimeoutMs, scrubActivationDelayMs, hoverPreviewFrameMs?, useVideoStream, fallbackAfterMs }. */ 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; // UseVideoStream defaults to true when unset. const useVideoStream = root.UseVideoStream !== false; // Prefer FallbackAfterMs; migrate legacy FallbackAfterSeconds if present. let fallbackAfterMs = FALLBACK_AFTER_MS_DEFAULT; if (Number.isFinite(root.FallbackAfterMs * 1) && root.FallbackAfterMs >= 0) { fallbackAfterMs = root.FallbackAfterMs * 1; } else if (Number.isFinite(root.FallbackAfterSeconds * 1) && root.FallbackAfterSeconds >= 0) { fallbackAfterMs = root.FallbackAfterSeconds * 1000; } return { previewOnHover: previewOnHover ?? HOVER_PREVIEW_DEFAULT_ENABLED, hoverTimeoutMs, scrubActivationDelayMs, hoverPreviewFrameMs, useVideoStream: useVideoStream ?? USE_VIDEO_STREAM_DEFAULT, fallbackAfterMs }; } function isHoverPreviewEnabled() { try { return !!getThumbnailScrubberConfig().previewOnHover; } catch (e) { WARN('Error reading hover preview config', e); return HOVER_PREVIEW_DEFAULT_ENABLED; } } function isUseVideoStreamEnabled() { try { return !!getThumbnailScrubberConfig().useVideoStream; } catch (e) { WARN('Error reading use video stream config', e); return USE_VIDEO_STREAM_DEFAULT; } } function getFallbackAfterMs() { try { const ms = getThumbnailScrubberConfig().fallbackAfterMs; if (!Number.isFinite(ms) || ms <= 0) return 0; return Math.round(ms); } catch (e) { WARN('Error reading fallback after config', e); return FALLBACK_AFTER_MS_DEFAULT; } } 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; } } /** * Get Jellyfin API token for stream URLs. Uses ApiClient when available, else localStorage credentials. */ function getJellyfinToken() { if (typeof ApiClient !== 'undefined' && ApiClient.accessToken) { return ApiClient.accessToken() || null; } try { const creds = localStorage.getItem('jellyfin_credentials'); if (!creds) return null; const parsed = JSON.parse(creds); const server = parsed.Servers && parsed.Servers[0]; return server ? server.AccessToken || null : null; } catch { return null; } } /** * Resolve playable video id for Series/Season (first episode). Returns itemId for Movie/Episode. */ async function getFirstEpisodeId(itemId, itemType) { if (itemType !== 'Series' && itemType !== 'Season') return itemId; const token = getJellyfinToken(); if (!token) return null; const baseUrl = typeof ApiClient !== 'undefined' && ApiClient.serverAddress ? ApiClient.serverAddress() : window.location.origin; try { let seriesId = itemId; let seasonId = itemId; if (itemType === 'Series') { const seasonsResp = await fetch(`${baseUrl}/Shows/${itemId}/Seasons`, { headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!seasonsResp.ok) return null; const seasons = await seasonsResp.json(); const firstSeason = (seasons.Items || []).find(s => s.IndexNumber === 1) || (seasons.Items || [])[0]; if (!firstSeason) return null; seasonId = firstSeason.Id; } else { const itemResp = await fetch(`${baseUrl}/Items/${itemId}`, { headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!itemResp.ok) return null; const item = await itemResp.json(); seriesId = item.SeriesId || item.ParentId || itemId; } const episodesResp = await fetch(`${baseUrl}/Shows/${seriesId}/Episodes?seasonId=${seasonId}`, { headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!episodesResp.ok) return null; const episodes = await episodesResp.json(); const firstEpisode = (episodes.Items || []).find(e => e.IndexNumber === 1) || (episodes.Items || [])[0]; return firstEpisode ? firstEpisode.Id : null; } catch (e) { WARN('getFirstEpisodeId failed', itemId, e); return null; } } /** * 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 clearPreviewFallbackTimer(state) { if (!state) return; if (state._previewFallbackTimer) { clearTimeout(state._previewFallbackTimer); state._previewFallbackTimer = null; } } async function fallbackVideoPreviewToTrickplay(state, startTimeTicks) { if (!state || !currentCardState || currentCardState !== state) return; clearPreviewFallbackTimer(state); try { if (state._videoPreviewEl) { try { state._videoPreviewEl.pause(); state._videoPreviewEl.removeAttribute('src'); state._videoPreviewEl.load(); } catch (e) { /* ignore */ } if (state._videoPreviewEl.parentNode) { state._videoPreviewEl.parentNode.removeChild(state._videoPreviewEl); } } } catch (e) { /* ignore */ } state._videoPreviewEl = null; if (state._videoPreviewWrapper && state._videoPreviewWrapper.parentNode) { try { state._videoPreviewWrapper.parentNode.removeChild(state._videoPreviewWrapper); } catch (e) { /* ignore */ } } state._videoPreviewWrapper = null; await ensureScrubberDataForHover(state); if (!currentCardState || currentCardState !== state) return; if (!state.scrubberData) { // No trickplay: scrub stays disabled via data-no-trickplay, but video hover can retry next time. state.hoverPreviewRunning = false; return; } state.hoverPreviewPositionTicks = startTimeTicks || 0; state.hoverPreviewFrameAccumMs = 0; // hoverPreviewRunning was set before calling startVideoStreamPreview startHoverPreviewLoop(); } function stopHoverPreview() { if (!currentCardState) return; const state = currentCardState; clearPreviewFallbackTimer(state); 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._videoPreviewEl) { try { state._videoPreviewEl.pause(); state._videoPreviewEl.removeAttribute('src'); state._videoPreviewEl.load(); } catch (e) { /* ignore */ } state._videoPreviewEl = null; } if (state._videoPreviewWrapper) { try { if (state._videoPreviewWrapper.parentNode) { state._videoPreviewWrapper.parentNode.removeChild(state._videoPreviewWrapper); } } catch (e) { /* ignore */ } state._videoPreviewWrapper = 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; const state = currentCardState; if (state._cardMouseLeaveHandler && state.card) { state.card.removeEventListener('mouseleave', state._cardMouseLeaveHandler); state._cardMouseLeaveHandler = null; } clearActivationTimer(); stopHoverPreview(); if (state.overlay) { const overlay = state.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; } } /** * When video stream preview is enabled: show live video stream in a sibling div immediately before cardImageContainer. * Starts with static=true; on timeout or play() failure, falls back to trickplay. */ async function startVideoStreamPreview(state, videoId, startTimeTicks) { if (!state || !state.cardImageContainer || currentCardState !== state) return; clearPreviewFallbackTimer(state); const token = getJellyfinToken(); if (!token) { WARN('startVideoStreamPreview: no API token'); return; } const baseUrl = typeof ApiClient !== 'undefined' && ApiClient.serverAddress ? ApiClient.serverAddress() : window.location.origin; const container = state.cardImageContainer; const parent = container.parentNode; if (!parent) return; const rect = container.getBoundingClientRect(); const cw = rect.width || 0; const ch = rect.height || 0; if (cw <= 0 || ch <= 0) return; // Fit 16:9 box inside container so video never overflows the card let boxWidth = cw; let boxHeight = Math.round(cw * 9 / 16); if (boxHeight > ch) { boxHeight = ch; boxWidth = Math.round(ch * 16 / 9); } const wrapper = document.createElement('div'); wrapper.className = 'kefin-hover-preview-sibling'; const parentRect = parent.getBoundingClientRect(); const style = wrapper.style; style.position = 'absolute'; style.left = (rect.left - parentRect.left) + 'px'; style.top = (rect.top - parentRect.top) + 'px'; style.width = rect.width + 'px'; style.height = rect.height + 'px'; if (getComputedStyle(parent).position === 'static') { parent.style.position = 'relative'; } const box = document.createElement('div'); box.className = 'kefin-hover-preview-box'; box.style.width = boxWidth + 'px'; box.style.height = boxHeight + 'px'; const frame = document.createElement('div'); frame.className = 'kefin-hover-preview-frame'; box.appendChild(frame); box._frame = frame; wrapper.appendChild(box); container.after(wrapper); state._videoPreviewWrapper = wrapper; const videoUrl = `${baseUrl}/Videos/${videoId}/stream?static=true&startTimeTicks=${startTimeTicks}&ApiKey=${token}`; const video = document.createElement('video'); video.className = 'kefin-hover-preview-video'; video.muted = true; video.playsInline = true; video.setAttribute('playsinline', ''); video.controls = false; video.src = videoUrl; box.appendChild(video); state._videoPreviewEl = video; let fallbackStarted = false; const clearStartupWatch = () => { clearPreviewFallbackTimer(state); video.removeEventListener('playing', onPlaybackStarted); video.removeEventListener('loadeddata', onPlaybackStarted); }; const onPlaybackStarted = () => { clearStartupWatch(); }; const switchToTrickplay = async (reason) => { if (fallbackStarted) return; if (!currentCardState || currentCardState !== state) return; if (state._videoPreviewEl !== video) return; fallbackStarted = true; clearStartupWatch(); WARN('Video stream preview ' + reason + ', falling back to trickplay', videoId); await fallbackVideoPreviewToTrickplay(state, startTimeTicks); }; video.addEventListener('playing', onPlaybackStarted); video.addEventListener('loadeddata', onPlaybackStarted); const fallbackAfterMs = getFallbackAfterMs(); if (fallbackAfterMs > 0) { state._previewFallbackTimer = setTimeout(() => { state._previewFallbackTimer = null; switchToTrickplay('timed out'); }, fallbackAfterMs); } video.play().catch(() => { switchToTrickplay('play() failed'); }); } 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; // Without trickplay, only video-stream hover is possible. if (!isUseVideoStreamEnabled() && state.overlay && state.overlay.hasAttribute('data-no-trickplay')) { return; } const timeoutMs = getHoverPreviewTimeoutMs(); const timeout = timeoutMs < 0 ? 0 : timeoutMs; state.hoverPreviewTimer = setTimeout(async () => { state.hoverPreviewTimer = null; if (!currentCardState || currentCardState.card !== state.card) return; if (isUseVideoStreamEnabled()) { const token = getJellyfinToken(); if (!token) return; let videoId = state.itemId; // Start from card's resume position (data-positionticks) when present let startTimeTicks = 0; const posAttr = state.card && state.card.getAttribute ? state.card.getAttribute('data-positionticks') : null; if (posAttr != null && posAttr !== '' && !Number.isNaN(Number(posAttr))) { startTimeTicks = Math.floor(Number(posAttr) / 10000); } state.hoverPreviewRunning = true; await startVideoStreamPreview(state, videoId, startTimeTicks); return; } await ensureScrubberDataForHover(state); if (!currentCardState || currentCardState.card !== state.card) return; if (!state.scrubberData) { state.hoverPreviewRunning = false; 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); const noTrickplay = overlay.hasAttribute('data-no-trickplay'); const card = ctx.card; function handleCardMouseLeave() { teardown(); } card.addEventListener('mouseleave', handleCardMouseLeave); currentCardState = { card: card, cardImageContainer: ctx.cardImageContainer, itemId: ctx.itemId, overlay: overlay, activationTimer: null, scrubberData: null, lastClientX: 0, lastClientY: 0, hoverPreviewTimer: null, hoverPreviewRunning: false, hoverPreviewLastTs: 0, hoverPreviewPositionTicks: 0, hoverPreviewFrameAccumMs: 0, _cardMouseLeaveHandler: handleCardMouseLeave }; // Scrub overlay stays hidden when there is no trickplay; video hover still attaches. if (!noTrickplay) { overlay.classList.add('is-visible'); } if (isHoverPreviewEnabled()) { maybeStartHoverPreview(); } } 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 buttons (play/resume, watched, watchlist, etc.) if (e.target && e.target.closest && e.target.closest('.cardOverlayButton, .cardOverlayButton-br')) { clearActivationTimer(); const overlay = state.overlay; if (overlay && !overlay._popoverOpen) { overlay.classList.remove('is-zone-active'); if (overlay._progressFill) overlay._progressFill.style.width = '0%'; } return; } state.lastClientX = e.clientX; state.lastClientY = e.clientY; const inZone = isInScrubZone(state.cardImageContainer, e.clientY); const overlay = state.overlay; if (inZone) { // Keep hover preview running while scrubbing; it stops only when the cursor leaves the card if (overlay.hasAttribute('data-no-trickplay')) return; if (!state.activationTimer) { const delayMs = getScrubActivationDelayMs(); state.activationTimer = setTimeout(() => { state.activationTimer = null; activateScrubberForCurrentCard(); }, delayMs); } scheduleUIUpdate(); } else { if (!overlay._popoverOpen) { overlay.classList.remove('is-zone-active'); 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(); } } } 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', 'Authorization': window.apiHelper.getAuthHeader() }, 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(); } })();