Bilibili-Evolved/registry/lib/components/style/replace-cover/index.ts

340 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import throttle from 'lodash/throttle'
import { defineComponentMetadata } from '@/components/define'
import { styledComponentEntry } from '@/components/styled-component'
import { ComponentEntry } from '@/components/types'
const previewCache = new Map<string, string>()
const concurrentLimit = 15
let running = 0
const queue: (() => Promise<void>)[] = []
/**
* Wraps an async function to limit concurrent executions.
* @param fn The async function to run.
*/
async function runWithLimit(fn: () => Promise<void>) {
if (running >= concurrentLimit) {
await new Promise<void>(resolve => {
queue.push(async () => {
await fn()
resolve()
})
})
return
}
running += 1
try {
await fn()
} finally {
running -= 1
if (queue.length > 0) {
const next = queue.shift()
next?.()
}
}
}
/**
* Fetches video preview image data (specific frame from a sprite sheet).
* Uses createImageBitmap with cropping for efficiency.
* Caches results.
* @param bvid The Bilibili video ID (BV...).
* @returns A data URL of the preview image, or null if unsuccessful.
*/
async function getVideoPreview(bvid: string): Promise<string | null> {
const cachedUrl = previewCache.get(bvid)
if (cachedUrl) {
return cachedUrl
}
try {
const apiUrl = new URL('https://api.bilibili.com/x/player/videoshot')
apiUrl.searchParams.set('bvid', bvid)
apiUrl.searchParams.set('index', '1')
let apiRes = await fetch(apiUrl.toString())
if (apiRes.status === 412) {
// 412 error, pause for 30 seconds and retry once.
await new Promise(r => setTimeout(r, 30000))
apiRes = await fetch(apiUrl.toString())
if (apiRes.status === 412) {
return null
}
}
const json = await apiRes.json()
if (json.code !== 0 || !json.data?.image) {
// 404 is a common case for videos without preview data, so we don't log it.
return null
}
const { image, index = [], img_x_len, img_y_len, img_x_size, img_y_size } = json.data
const totalPerImage = img_x_len * img_y_len
const mid = Array.isArray(index) && index.length > 0 ? Math.floor((index.length - 1) / 2) : 0
const pageIndex = Math.floor(mid / totalPerImage)
if (pageIndex >= image.length) {
return null
}
const indexInPage = mid % totalPerImage
const xIndex = indexInPage % img_x_len
const yIndex = Math.floor(indexInPage / img_x_len)
const sx = xIndex * img_x_size
const sy = yIndex * img_y_size
const sw = img_x_size
const sh = img_y_size
const fullImageUrl = image[pageIndex].startsWith('//')
? `https:${image[pageIndex]}`
: image[pageIndex]
const imgRes = await fetch(fullImageUrl, { mode: 'cors' })
if (!imgRes.ok) {
return null
}
const imgBlob = await imgRes.blob()
let imageBitmap: ImageBitmap
try {
imageBitmap = await createImageBitmap(imgBlob, sx, sy, sw, sh, {})
} catch (bitmapError) {
console.error('[replaceCover] createImageBitmap error:', bitmapError)
// Fallback to canvas method if createImageBitmap fails
try {
const dataUrl = await new Promise<string>((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = sw
canvas.height = sh
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Could not get canvas context in fallback.'))
return
}
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh)
const dataUrlResult = canvas.toDataURL('image/jpeg', 0.9)
resolve(dataUrlResult)
} catch (e) {
console.error('[replaceCover] canvas error:', e)
reject(e)
}
}
img.onerror = e =>
reject(new Error(`Image load error in fallback for ${fullImageUrl}: ${e}`))
img.src = fullImageUrl
})
previewCache.set(bvid, dataUrl)
return dataUrl
} catch (fallbackError) {
console.error('[replaceCover] fallback error:', fallbackError)
return null
}
}
const canvas = document.createElement('canvas')
canvas.width = img_x_size
canvas.height = img_y_size
const ctx = canvas.getContext('2d')
if (!ctx) {
imageBitmap.close()
return null
}
ctx.drawImage(imageBitmap, 0, 0)
imageBitmap.close()
const dataUrl = canvas.toDataURL('image/jpeg', 0.9)
previewCache.set(bvid, dataUrl)
return dataUrl
} catch (error) {
console.error('[replaceCover] Error fetching video preview:', error)
return null
}
}
/**
* Processes a single video card element to replace its cover.
* @param card The video card HTMLElement.
*/
async function processVideoCard(card: HTMLElement) {
const videoLink = card.querySelector('a.bili-video-card__image--link')
if (!videoLink) {
return
}
const href = videoLink.getAttribute('href')
const bvMatch = href?.match(/\/video\/(BV[\w]+)/)
if (!bvMatch) {
return
}
const bvid = bvMatch[1]
const imgContainer = videoLink.querySelector('.bili-video-card__image--wrap')
if (!imgContainer || imgContainer.querySelector('.custom-preview-img')) {
return
}
const originalImg = imgContainer.querySelector('picture img') as HTMLImageElement | null
if (!originalImg) {
return
}
const customImg = document.createElement('img')
customImg.className = 'custom-preview-img'
if (originalImg.alt) {
customImg.alt = originalImg.alt
}
if (originalImg.style.aspectRatio) {
customImg.style.aspectRatio = originalImg.style.aspectRatio
}
imgContainer.appendChild(customImg)
await runWithLimit(async () => {
try {
const previewUrl = await getVideoPreview(bvid)
if (!previewUrl) {
customImg.remove()
return
}
await new Promise<void>(resolve => {
const tempImg = new Image()
tempImg.onload = () => {
customImg.src = previewUrl
customImg.classList.add('loaded')
resolve()
}
tempImg.onerror = () => {
customImg.remove()
resolve() // Resolve anyway to not block other promises
}
tempImg.src = previewUrl
})
} catch (e) {
console.error('[replaceCover] Error processing video card:', e)
customImg.remove()
}
})
}
/**
* Processes a list of potential video card HTMLElements.
* @param elements Array of HTMLElements to check and process.
*/
async function replaceCover(elements: HTMLElement[]) {
const videoCardsToProcess = elements.filter(card => card.classList?.contains('bili-video-card'))
if (videoCardsToProcess.length === 0) {
return
}
await Promise.allSettled(videoCardsToProcess.map(card => processVideoCard(card)))
}
/**
* MutationObserver callback to filter added nodes and trigger cover replacement.
* @param mutationsList List of mutation records.
*/
function videoFilter(mutationsList: MutationRecord[]) {
const filteredElements: HTMLElement[] = []
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of Array.from(mutation.addedNodes)) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement
if (element.classList?.contains('bili-video-card')) {
filteredElements.push(element)
}
const descendantCards = element.querySelectorAll<HTMLElement>('.bili-video-card')
descendantCards.forEach(descendantCard => filteredElements.push(descendantCard))
}
}
}
}
const uniqueElements = Array.from(new Set(filteredElements))
if (uniqueElements.length > 0) {
replaceCover(uniqueElements)
}
}
/**
* The main entry point for the component.
*/
const replaceCoverComponent: ComponentEntry = async () => {
const containerSelectors = [
'main > .feed2 > .recommended-container_floor-aside > .container',
'main > .feed2 > .container',
'.recommended-container_floor-aside > .container',
'.container.is-adapting',
'.recommend-page-container .video-card-list',
'#page-video .tab-list-box .video-card-list',
'#bili_bangumi_watch .video-card-list',
'.search-results-video .video-list',
'.space-video-list', // .bili-video-card is the item itself, we need the parent list
]
const container =
(containerSelectors
.map(selector => document.querySelector(selector))
.find(el => el !== null) as HTMLElement | null) ?? document.body
const scanAllVideoCards = () => {
const allCards = document.querySelectorAll<HTMLElement>('.bili-video-card')
if (allCards.length === 0) {
return
}
const cardsToProcess = Array.from(allCards).filter(card => {
const imgContainer = card.querySelector('.bili-video-card__image--wrap')
if (!imgContainer) {
return false
}
const customImg = imgContainer.querySelector('.custom-preview-img') as HTMLImageElement
return !customImg || !customImg.src
})
if (cardsToProcess.length > 0) {
// Clean up placeholders that failed to load previously
cardsToProcess.forEach(card => {
const customImg = card.querySelector('.custom-preview-img')
if (customImg && !(customImg as HTMLImageElement).src) {
customImg.remove()
}
})
replaceCover(cardsToProcess)
}
}
// Initial scan
const existingCards = document.querySelectorAll<HTMLElement>('.bili-video-card')
replaceCover(Array.from(existingCards))
// Observe for dynamically added cards
const observer = new MutationObserver(videoFilter)
observer.observe(container, { childList: true, subtree: true })
// Scan on scroll and resize for cards loaded by virtual lists
const throttledScan = throttle(scanAllVideoCards, 300)
window.addEventListener('resize', throttledScan)
window.addEventListener('scroll', throttledScan)
// Note: The original return function for cleanup is removed as per the plan.
// The script's lifecycle manager should handle observer disconnection and event listener removal if needed.
}
export const component = defineComponentMetadata({
name: 'replaceCover',
author: {
name: 'UcnacDx2',
link: 'https://github.com/UcnacDx2',
},
tags: [componentsTags.style],
displayName: '替换标题党封面',
description: '将视频卡片的封面替换为视频预览帧杜绝图文不符。致敬DeArrow。',
urlInclude: ['www.bilibili.com'],
// 将样式和逻辑入口合并到 entry 属性中
entry: styledComponentEntry(
() => import('./index.scss'), // 第一个参数:样式
replaceCoverComponent, // 第二个参数:组件逻辑
),
// 不再有独立的 style 属性
})