// ==UserScript== // @name Jellyfin/Emby 显示剧照 (Show Jellyfin/Emby images under the extrafanart folder) // @namespace http://tampermonkey.net/ // @version 1.2.2 // @description Jellyfin/Emby 显示剧照 // @author Squirtle // @match *://*/web/index.html* // @match *://*/*/web/index.html* // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_getValue // @grant GM_setValue // @license MIT // @downloadURL none // ==/UserScript== ;(function () { 'use strict' let startImageIndex = GM_getValue('startImageIndex', 2) let endImageIndex = 0 let currentZoomedImageIndex = -1 let startMenuEventId = null let itemId = null const imageContainer = createImageContainer() const zoomedMask = createZoomedMask() const zoomedImage = zoomedMask.querySelector('#jv-zoom-img') const zoomedImageWrapper = zoomedMask.querySelector('#jv-zoom-img-wrapper') const zoomedImageDescription = zoomedMask.querySelector('#jv-zoom-img-desc') const leftButton = zoomedMask.querySelector('.jv-left-btn') const rightButton = zoomedMask.querySelector('.jv-right-btn') function getCurrentItemId() { return location.hash.match(/id\=(\w+)/)?.[1] ?? null } function getBackgroundImageSrc(index) { const currentItemId = getCurrentItemId() return currentItemId && `${location.origin}/Items/${currentItemId}/Images/Backdrop/${index}?maxWidth=1280` } function createImageContainer() { const container = document.createElement('div') container.id = 'jv-image-container' return container } function createZoomedMask() { const mask = document.createElement('div') mask.id = 'jv-zoom-mask' mask.innerHTML = `
` return mask } function showZoomedMask(index) { const imageSrc = getBackgroundImageSrc(index) if (!imageSrc) return document.body.classList.add('no-scroll') zoomedMask.style.display = 'flex' zoomedImage.src = imageSrc setTimeout(() => { zoomedImageWrapper.classList.add('do-zoom') }) zoomedImageDescription.innerHTML = `${currentZoomedImageIndex - startImageIndex + 1} of ${endImageIndex - startImageIndex + 1}` } function hideZoomedMask() { zoomedImageWrapper.classList.remove('do-zoom') document.body.classList.remove('no-scroll') currentZoomedImageIndex = -1 zoomedImageDescription.innerHTML = '' setTimeout(() => { zoomedMask.style.display = 'none' }, 400) } function createImageElement(index) { const imageSrc = getBackgroundImageSrc(index) const imageElement = document.createElement('img') imageElement.src = imageSrc imageElement.className = 'jv-image' imageElement.onclick = function () { currentZoomedImageIndex = index showZoomedMask(index) } return imageElement } function appendImagesToContainer(imageCount) { const imageFragment = document.createDocumentFragment() for (let index = startImageIndex; index <= imageCount; index++) { const imageElement = createImageElement(index) imageFragment.appendChild(imageElement) } imageContainer.appendChild(imageFragment) } function showContainer(imageCount) { if (imageCount > 0) { const primaryDiv = document.querySelector('#itemDetailPage:not(.hide) #castCollapsible') || document.querySelector('.itemView:not(.hide) .peopleSection') if (primaryDiv) { imageContainer.style.display = 'block' primaryDiv.insertAdjacentElement('afterend', imageContainer) } } } function isDetailsPage() { return location.hash.startsWith('#!/details?id=') || location.hash.startsWith('#!/item?id=') } async function getEndImageIndex() { let left = startImageIndex let right = startImageIndex + 20 let found = false while (left <= right) { let mid = Math.floor((left + right) / 2) const newSrc = getBackgroundImageSrc(mid) try { const response = await fetch(newSrc, { method: 'HEAD' }) if (!response.ok) throw new Error('Image not found.') found = true left = mid + 1 } catch (error) { right = mid - 1 } } return found ? right : 0 } async function loadImages() { if (!isDetailsPage()) return const currentItemId = getCurrentItemId() if (!currentItemId) return if (itemId !== currentItemId) { endImageIndex = await getEndImageIndex() } itemId = currentItemId imageContainer.innerHTML = '' appendImagesToContainer(endImageIndex) showContainer(endImageIndex) } function handleLeftButtonClick(e) { e.stopPropagation() if (currentZoomedImageIndex === -1) return if (currentZoomedImageIndex > startImageIndex) { currentZoomedImageIndex-- } else { currentZoomedImageIndex = endImageIndex } showZoomedMask(currentZoomedImageIndex) } function handleRightButtonClick(e) { e.stopPropagation() if (currentZoomedImageIndex === -1) return if (currentZoomedImageIndex < endImageIndex) { currentZoomedImageIndex++ } else { currentZoomedImageIndex = startImageIndex } showZoomedMask(currentZoomedImageIndex) } function handleKeydown(e) { if (currentZoomedImageIndex === -1) return e.stopPropagation() if (e.key === 'ArrowLeft') { handleLeftButtonClick(e) } else if (e.key === 'ArrowRight') { handleRightButtonClick(e) } else if (e.key === 'Escape') { hideZoomedMask() } } function clickMenu() { GM_unregisterMenuCommand(startMenuEventId) if (startImageIndex < 2) { startImageIndex++ } else { startImageIndex = 0 } GM_setValue('startImageIndex', startImageIndex) registerMenuListener() hideZoomedMask() loadImages() } function registerMenuListener() { startMenuEventId = GM_registerMenuCommand(`从第${startImageIndex + 1}张开始`, clickMenu, { autoClose: false, accessKey: 's', title: '第一、二张剧照一般是封面,可以选择是否显示它们' }) } function registerEventListeners() { document.addEventListener('viewshow', () => setTimeout(loadImages)) document.addEventListener('keydown', handleKeydown) leftButton.addEventListener('click', handleLeftButtonClick) rightButton.addEventListener('click', handleRightButtonClick) zoomedMask.addEventListener('click', hideZoomedMask) zoomedImageWrapper.addEventListener('click', handleRightButtonClick) } function start() { document.body.appendChild(zoomedMask) registerEventListeners() registerMenuListener() } start() // 注入css const css = ` body.no-scroll { overflow: hidden; } #jv-image-container { display: none; backdrop-filter: blur(10px); box-shadow: 0 0 20px rgba(0, 0, 0, 0.2); padding: 10px; border-radius: 25px; } .jv-image { max-height: 200px; margin: 10px; cursor: zoom-in; user-select: none; } #jv-zoom-mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); display: none; justify-content: space-between; align-items: flex-start; padding: 20px; z-index: 1100; overflow: auto; cursor: zoom-out; } #jv-zoom-img-wrapper { display: flex; flex-flow: column wrap; align-items: flex-end; user-select: none; cursor: pointer; transform: scale(0); transition: transform 0.4s ease-in-out; margin-top: auto; margin-bottom: auto; } #jv-zoom-img-wrapper.do-zoom { transform: scale(1); } #jv-zoom-img { width: 100%; } #jv-zoom-img-desc { color: #cccccc; font-size: 12px; margin-top: 4px; } .jv-zoom-btn { padding: 20px; cursor: pointer; background: transparent; border: 0; outline: none; box-shadow: none; opacity: 0.7; display: flex; justify-content: center; align-items: center; margin-top: auto; margin-bottom: auto; } .jv-zoom-btn:hover { opacity: 1; } .jv-zoom-btn:before { content: ''; display: block; width: 0; height: 0; border: medium inset transparent; border-top-width: 21px; border-bottom-width: 21px; } .jv-zoom-btn.jv-left-btn:before { border-right: 27px solid white; } .jv-zoom-btn.jv-right-btn:before { border-left: 27px solid white; } ` GM_addStyle(css) })()