diff --git a/.gitignore b/.gitignore index 879aa1eb..0ff483a1 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ package-lock.json # iconify dist files src/@iconify/*.js public/plugin_icon/** + +# AI +.omc/ \ No newline at end of file diff --git a/package.json b/package.json index e128f639..42070e4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "moviepilot", "version": "2.11.4", + "license": "MIT", "private": true, "type": "module", "bin": "dist/service.js", @@ -28,6 +29,7 @@ "@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/vue3": "^6.1.15", "@iconify/utils": "^2.2.1", + "@tanstack/vue-virtual": "^3.13.24", "@types/crypto-js": "^4.2.2", "@types/js-cookie": "^3.0.6", "@vue-flow/background": "^1.3.2", diff --git a/src/@core/utils/image.ts b/src/@core/utils/image.ts index 546c0d92..9c0e1ace 100644 --- a/src/@core/utils/image.ts +++ b/src/@core/utils/image.ts @@ -14,33 +14,95 @@ function rgbStringToHex(rgbArray: number[]): string { return `#${toHex(r)}${toHex(g)}${toHex(b)}` } +// 主色调缓存:相同 URL 不重复经过 ColorThief 的 canvas 解码 +const DOMINANT_COLOR_CACHE_MAX = 200 +const dominantColorCache = new Map() + +function rememberDominantColor(key: string, value: string) { + if (!key) return + if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_MAX) { + const first = dominantColorCache.keys().next().value + if (first !== undefined) dominantColorCache.delete(first) + } + dominantColorCache.set(key, value) +} + // 提取主要颜色 export async function getDominantColor(image: HTMLImageElement): Promise { - const colorThief = new ColorThief() - const dominantColor = colorThief.getColor(image) - return rgbStringToHex(dominantColor) + const cacheKey = image?.currentSrc || image?.src || '' + const cached = cacheKey ? dominantColorCache.get(cacheKey) : undefined + if (cached) return cached + try { + const colorThief = new ColorThief() + const dominantColor = colorThief.getColor(image) + const hex = rgbStringToHex(dominantColor) + rememberDominantColor(cacheKey, hex) + return hex + } catch (e) { + console.warn('getDominantColor failed', e) + return '#28A9E1' + } +} + +// 预加载缓存:已成功加载的 URL 不再重复创建 Image 对象 +const PRELOAD_CACHE_MAX = 50 +const preloadedUrls = new Set() + +function rememberPreloaded(url: string) { + if (!url) return + if (preloadedUrls.size >= PRELOAD_CACHE_MAX) { + const first = preloadedUrls.values().next().value + if (first !== undefined) preloadedUrls.delete(first) + } + preloadedUrls.add(url) } // 预加载图片 export async function preloadImage(url: string): Promise { + if (!url) return false + if (preloadedUrls.has(url)) return true return new Promise(resolve => { const img = new Image() + img.decoding = 'async' + let settled = false - img.onload = () => resolve(true) - img.onerror = () => resolve(false) + const finish = (ok: boolean) => { + if (settled) return + settled = true + img.onload = null + img.onerror = null + window.clearTimeout(timeout) + if (ok) rememberPreloaded(url) + else img.src = '' // 释放解码位图 + resolve(ok) + } - // 设置超时,防止图片长时间加载 - const timeout = setTimeout(() => { - img.src = '' - resolve(false) - }, 5000) // 5秒超时 + img.onload = () => finish(true) + img.onerror = () => finish(false) + + const timeout = window.setTimeout(() => finish(false), 5000) img.src = url - // 如果图片已经缓存,onload可能不会触发 - if (img.complete) { - clearTimeout(timeout) - resolve(true) - } + // 命中浏览器缓存时 onload 可能不会触发 + if (img.complete && img.naturalWidth > 0) finish(true) }) } + +// TMDB 图片域名地址(仅作为兜底,调用方应优先用 globalSettings.TMDB_IMAGE_DOMAIN) +const TMDB_PATH_RE = /\/t\/p\/(original|w\d+|h\d+|w\d+_and_h\d+_bestv2)\// + +/** + * 把 TMDB 图片 URL 重置到指定渲染尺寸。非 TMDB URL(豆瓣 / Bangumi / 自定义代理)原样返回。 + * 用于在卡片场景下避免下载 / 解码 1MP+ 的原图。 + * + * 常见尺寸:w92 / w154 / w185 / w342 / w500 / w780 / original(poster, backdrop) + * w45 / w185 / h632 / original(profile) + */ +export function tmdbResize( + url: string | undefined | null, + size: 'w92' | 'w154' | 'w185' | 'w342' | 'w500' | 'w780' | 'original', +): string { + if (!url) return '' + return url.replace(TMDB_PATH_RE, `/t/p/${size}/`) +} diff --git a/src/api/index.ts b/src/api/index.ts index e252dea4..12a18e77 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -45,6 +45,13 @@ api.interceptors.response.use( return response.data }, error => { + // 请求被主动取消(路由切换 / 组件卸载触发 requestOptimizer abort)。 + // 这不是错误:原样透传 cancel error,让调用方用 axios.isCancel() 识别并静默处理。 + // 不能落到下面 new Error(error.message) 分支——那会把 cancel 签名抹掉, + // 调用方只能看到一个 message='canceled' 的普通 Error,被迫当错误打日志。 + if (axios.isCancel(error)) { + return Promise.reject(error) + } if (!error.response) { // 网络错误或请求超时 - 通知离线状态管理系统 const isNetworkError = diff --git a/src/components/cards/MediaCard.vue b/src/components/cards/MediaCard.vue index fcf59935..1568951a 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -1,6 +1,7 @@