fix(glass): unify content surfaces and renderer eligibility (#653)

This commit is contained in:
InfinityPacer
2026-08-10 06:54:29 +08:00
committed by GitHub
parent 309ce3c92e
commit eb3b7b63b6
8 changed files with 899 additions and 93 deletions
+93 -5
View File
@@ -62,6 +62,34 @@ const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false)
// 图片请求代际隔离复用卡片的迟到事件,避免旧海报改变新媒体的 renderer 资格。
const imageRequestRevision = ref(0)
// renderer 只能在真实海报完成可见淡入后退出,资源 load 本身不代表像素已完全覆盖卡片。
const hasCompletedPosterReveal = ref(false)
const POSTER_REVEAL_FALLBACK_MS = 400
let posterRevealFallbackTimer: number | null = null
/** 清理当前海报的视觉覆盖状态与兜底提交。 */
function resetPosterRevealState() {
hasCompletedPosterReveal.value = false
if (posterRevealFallbackTimer === null) return
window.clearTimeout(posterRevealFallbackTimer)
posterRevealFallbackTimer = null
}
/** 仅允许当前真实海报请求完成 renderer 排除提交。 */
function completePosterReveal(revision: number, usesFallback: boolean) {
if (revision !== imageRequestRevision.value || usesFallback) return
if (posterRevealFallbackTimer !== null) {
window.clearTimeout(posterRevealFallbackTimer)
posterRevealFallbackTimer = null
}
hasCompletedPosterReveal.value = true
}
// 当前订阅状态
const isSubscribed = ref(false)
@@ -399,6 +427,54 @@ const getImgUrl: Ref<string> = computed(() => {
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
const hasLoadedRealPoster = computed(
() =>
Boolean(props.media?.poster_path) && isImageLoaded.value && hasCompletedPosterReveal.value && !imageLoadError.value,
)
/** 为当前图片实例绑定不可跨媒体复用的成功与失败回调。 */
const imageRequest = computed(() => {
const revision = imageRequestRevision.value
const src = getImgUrl.value
const usesFallback = imageLoadError.value || !props.media?.poster_path
return {
handleError: () => {
if (revision !== imageRequestRevision.value || usesFallback) return
resetPosterRevealState()
isImageLoaded.value = false
imageLoadError.value = true
imageRequestRevision.value += 1
},
handleLoad: () => {
if (revision !== imageRequestRevision.value) return
resetPosterRevealState()
isImageLoaded.value = true
if (!usesFallback) {
posterRevealFallbackTimer = window.setTimeout(
() => completePosterReveal(revision, usesFallback),
POSTER_REVEAL_FALLBACK_MS,
)
}
},
handleReveal: (event: TransitionEvent) => {
if (
event.propertyName !== 'opacity' ||
!(event.target instanceof Element) ||
!event.target.matches('.v-img__img')
) {
return
}
completePosterReveal(revision, usesFallback)
},
key: revision,
src,
}
})
// 获取媒体类型文本
function getMediaTypeText(type: string | undefined) {
if (!type) return ''
@@ -425,13 +501,21 @@ watch(isSubscribed, subscribed => {
})
watch(
() => props.media,
() => {
[() => props.media, () => props.media?.poster_path],
([media], [previousMedia]) => {
imageRequestRevision.value += 1
resetPosterRevealState()
isImageLoaded.value = false
imageLoadError.value = false
// 海报补全只重置图片代际;详情和订阅状态绑定媒体对象身份。
if (media === previousMedia) return
resetMediaCardDetailState()
subscribedSeasons.value = []
subscribedSeasonModes.value = {}
subscribedSeasonsLoaded.value = false
},
{ flush: 'sync' },
)
onMounted(() => {
@@ -445,6 +529,7 @@ onActivated(resetMediaCardDetailState)
onDeactivated(resetMediaCardDetailState)
onBeforeUnmount(() => {
resetPosterRevealState()
resetMediaCardDetailState()
document.removeEventListener('pointerdown', handleDocumentPointerDown)
observer.value?.disconnect()
@@ -461,6 +546,7 @@ onBeforeUnmount(() => {
:height="props.height"
:width="props.width"
:ripple="false"
:data-glass-optical-mode="hasLoadedRealPoster ? 'excluded' : undefined"
class="app-hover-lift-card outline-none ring-gray-500 media-card"
:class="{
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
@@ -470,12 +556,14 @@ onBeforeUnmount(() => {
@click.stop="handleMediaCardClick(hover.isHovering)"
>
<VImg
:key="imageRequest.key"
aspect-ratio="2/3"
:src="getImgUrl"
:src="imageRequest.src"
class="object-cover aspect-w-2 aspect-h-3"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
@load="imageRequest.handleLoad"
@error="imageRequest.handleError"
@transitionend="imageRequest.handleReveal"
>
<template #placeholder>
<div class="w-full h-full">
+136 -25
View File
@@ -8,7 +8,7 @@ import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/suppo
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { defineComponent, h } from 'vue'
import { defineComponent, h, reactive, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -89,6 +89,50 @@ interface RenderCardOptions {
superUser?: boolean
}
interface ControlledImageRequest {
/** 模拟当前 VImg 请求失败。 */
fail: () => void
/** 模拟当前 VImg 请求成功。 */
load: () => void
/** 模拟当前 VImg 的 opacity 淡入完成。 */
reveal: () => void
/** 当前 VImg 实例发起的图片地址。 */
src: string
}
/** 创建可保留旧实例回调的图片替身,用于验证媒体复用时的迟到事件隔离。 */
function createControlledImageStub(requests: ControlledImageRequest[]) {
return defineComponent({
name: 'VImg',
emits: ['error', 'load'],
props: { src: String },
setup(props, { emit, slots }) {
const src = props.src ?? ''
const imageElement = ref<HTMLImageElement | null>(null)
const request = {
fail: () => emit('error', src),
load: () => emit('load', src),
reveal: () => {
const event = new Event('transitionend', { bubbles: true }) as TransitionEvent
Object.defineProperty(event, 'propertyName', { value: 'opacity' })
imageElement.value?.dispatchEvent(event)
},
src,
}
requests.push(request)
return () =>
h('div', { 'data-src': src }, [
h('img', {
ref: imageElement,
class: 'v-img__img',
}),
slots.default?.(),
])
},
})
}
/** 使用指定媒体信息和用户权限渲染媒体卡片。 */
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
return renderWithProviders(MediaCard, {
@@ -414,17 +458,22 @@ describe('MediaCard', () => {
expect(dialogProps.selected).toEqual([])
})
it('loads matching TV seasons before opening the subscription dialog', async () => {
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
it('preserves loaded TV seasons when the same media receives a new poster', async () => {
const media = reactive(createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' }))
const subscribeListRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }),
subscribeListHandler([
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
{ id: 84, tmdbid: 9551, type: '电' },
]),
subscribeListHandler(
[
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
{ id: 84, tmdbid: 9551, type: '电影' },
],
200,
subscribeListRequest,
),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
),
@@ -443,6 +492,19 @@ describe('MediaCard', () => {
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
subscribedSeasons: [1, 3],
})
expect(subscribeListRequest).toHaveBeenCalledOnce()
media.poster_path = '/original/updated.jpg'
mocks.openSharedDialog.mockClear()
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
expect(subscribeListRequest).toHaveBeenCalledOnce()
const [, updatedDialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
expect(updatedDialogProps).toMatchObject({
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
subscribedSeasons: [1, 3],
})
})
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
@@ -533,35 +595,84 @@ describe('MediaCard', () => {
type: '电视剧',
vote_average: 8.6,
})
const VImgStub = defineComponent({
name: 'VImg',
emits: ['error', 'load'],
props: { src: String },
/** 渲染可主动触发图片成功和失败事件的测试替身。 */
setup(props, { emit, slots }) {
return () =>
h('div', { 'data-src': props.src }, [
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
slots.default?.(),
])
},
})
const requests: ControlledImageRequest[] = []
const VImgStub = createControlledImageStub(requests)
const { container } = await renderWithProviders(MediaCard, {
props: { media, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: VImgStub } },
})
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
requests[0].load()
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
requests[0].reveal()
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
expect(container).toHaveTextContent('TV')
expect(container).toHaveTextContent('8.6')
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
requests[0].fail()
await waitFor(() =>
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
)
await waitFor(() => expect(requests.some(request => request.src.includes('no-image'))).toBe(true))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
const fallbackRequest = requests.find(request => request.src.includes('no-image'))
fallbackRequest?.load()
await waitFor(() => expect(getCard(container)).toHaveClass('ring-1'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
fallbackRequest?.reveal()
await Promise.resolve()
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
})
it('keeps placeholder-only cards inside the renderer after the image loads', async () => {
const requests: ControlledImageRequest[] = []
const { container } = await renderWithProviders(MediaCard, {
props: { media: createMediaInfo({ poster_path: undefined, tmdb_id: 9553 }), width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: createControlledImageStub(requests) } },
})
requests[0].load()
await waitFor(() => expect(getCard(container)).toHaveClass('ring-1'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
})
it('ignores a previous poster load after the card is reused for another media item', async () => {
const requests: ControlledImageRequest[] = []
const mediaA = createMediaInfo({ poster_path: '/original/a.jpg', title: '媒体 A', tmdb_id: 9554 })
const mediaB = createMediaInfo({ poster_path: '/original/b.jpg', title: '媒体 B', tmdb_id: 9555 })
const { container, rerender } = await renderWithProviders(MediaCard, {
props: { media: mediaA, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: createControlledImageStub(requests) } },
})
requests[0].load()
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
await rerender({ media: mediaB, width: '9rem' })
await waitFor(() => expect(requests.some(request => request.src.includes('/w500/b.jpg'))).toBe(true))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
requests[0].reveal()
await Promise.resolve()
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
const currentRequest = requests.find(request => request.src.includes('/w500/b.jpg'))
currentRequest?.load()
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
currentRequest?.reveal()
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
})
it('renders the AniList source badge after the poster loads', async () => {