// ==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)
})()