fix(glass): stabilize optical surfaces and page transitions (#594)

This commit is contained in:
InfinityPacer
2026-07-27 17:33:05 +08:00
committed by GitHub
parent 15ef332ea2
commit 3daa031a04
16 changed files with 429 additions and 80 deletions
+16 -2
View File
@@ -322,7 +322,21 @@ const emit = defineEmits(['action'])
`nativeSubscribe` 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。 `nativeSubscribe` 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
### 5.6 调用主应用原生订阅 ### 5.6 玻璃光学表面
主应用的 `Page``Config``AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
```html
<div data-glass-optical-surface data-glass-optical-mode="dynamic">
<!-- 插件自己的视觉内容 -->
</div>
```
使用时需同时声明 `data-glass-optical-surface``data-glass-optical-mode="dynamic"`。模式会从最近的祖先容器继承,因此显式声明的动态子表面不会沿用宿主的静态模式。该合同适用于插件在 `Page``Config``AppPage` 中自行渲染并控制的区域;主应用生成的插件列表、插件市场卡片、`Dashboard` 及其他宿主 DOM 不属于插件的修改边界。
动态模式只在主应用启用玻璃主题和实时光学能力时生效。其他主题、降低动态效果或光学能力不可用时,插件必须保持内容与交互正常,不应依赖动态光学表达业务状态或必要反馈。
### 5.7 调用主应用原生订阅
`Page``Config``Dashboard``AppPage` 都会收到 `nativeSubscribe(mediaInfo)` prop。插件传入媒体信息后,电视剧会打开主应用的选季抽屉,电影会进入现有电影订阅流程。宿主也会用 `moviepilot:nativeSubscribe` 键提供同一个方法,深层子组件可以使用 `inject`,无需逐层传递 prop。 `Page``Config``Dashboard``AppPage` 都会收到 `nativeSubscribe(mediaInfo)` prop。插件传入媒体信息后,电视剧会打开主应用的选季抽屉,电影会进入现有电影订阅流程。宿主也会用 `moviepilot:nativeSubscribe` 键提供同一个方法,深层子组件可以使用 `inject`,无需逐层传递 prop。
@@ -357,7 +371,7 @@ async function subscribeMedia(mediaInfo: Record<string, unknown>) {
`success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。 `success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。
### 5.7 调用主应用 Toast ### 5.8 调用主应用 Toast
`Page``Config``Dashboard``AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器: `Page``Config``Dashboard``AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
+3 -1
View File
@@ -29,6 +29,7 @@ import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composab
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { import {
BACKGROUND_ROTATION_GRACE_MS, BACKGROUND_ROTATION_GRACE_MS,
createBackgroundCandidateOrderResolver,
findFirstAvailableBackground, findFirstAvailableBackground,
preloadBackgroundRotationImages, preloadBackgroundRotationImages,
shouldAllowBackgroundRotation, shouldAllowBackgroundRotation,
@@ -87,6 +88,7 @@ const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false) const isBackgroundCrossfading = ref(false)
const backgroundCrossfadeStartedAt = ref(0) const backgroundCrossfadeStartedAt = ref(0)
const pendingOpticalBackgroundImage = ref('') const pendingOpticalBackgroundImage = ref('')
const resolveBackgroundCandidateOrder = createBackgroundCandidateOrderResolver()
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle() const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
const preferredMotion = usePreferredReducedMotion() const preferredMotion = usePreferredReducedMotion()
const backgroundRotationGraceActive = ref(false) const backgroundRotationGraceActive = ref(false)
@@ -718,7 +720,7 @@ async function removeLoadingWithStateCheck() {
async function loadBackgroundImages(loadVersion: number, retryCount = 0) { async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
const maxRetries = 3 const maxRetries = 3
try { try {
const images = await fetchBackgroundImages() const images = resolveBackgroundCandidateOrder(await fetchBackgroundImages())
if (loadVersion !== backgroundLoadVersion) return if (loadVersion !== backgroundLoadVersion) return
const firstAvailableIndex = await findFirstAvailableBackground({ const firstAvailableIndex = await findFirstAvailableBackground({
+7 -2
View File
@@ -173,7 +173,12 @@ onBeforeMount(async () => {
<template> <template>
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value"> <VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 --> <!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"> <VCard
v-if="renderMode === 'vuetify'"
:title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"
data-glass-optical-surface
data-glass-optical-mode="static-material"
>
<VDialogCloseBtn @click="emit('close')" /> <VDialogCloseBtn @click="emit('close')" />
<VDivider /> <VDivider />
<LoadingBanner v-if="!isRefreshed" class="mt-5" /> <LoadingBanner v-if="!isRefreshed" class="mt-5" />
@@ -208,7 +213,7 @@ onBeforeMount(async () => {
</VCardActions> </VCardActions>
</VCard> </VCard>
<!-- Vue 渲染模式 --> <!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'"> <VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
<VCardText class="pa-0"> <VCardText class="pa-0">
<component <component
:is="dynamicComponent" :is="dynamicComponent"
+7 -2
View File
@@ -136,7 +136,12 @@ onMounted(() => {
<template> <template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value"> <VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 --> <!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`"> <VCard
v-if="renderMode === 'vuetify'"
:title="`${props.plugin?.plugin_name}`"
data-glass-optical-surface
data-glass-optical-mode="static-material"
>
<VDialogCloseBtn @click="emit('close')" /> <VDialogCloseBtn @click="emit('close')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" /> <LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else class="min-h-40"> <VCardText v-else class="min-h-40">
@@ -158,7 +163,7 @@ onMounted(() => {
/> />
</VCard> </VCard>
<!-- Vue 渲染模式 --> <!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'"> <VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
<VCardText class="pa-0"> <VCardText class="pa-0">
<component <component
:is="dynamicComponent" :is="dynamicComponent"
@@ -0,0 +1,67 @@
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { defineComponent, onMounted, provide, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
describe('useDynamicHeaderTab', () => {
it('registers before mount and restores within the keep-alive activation flush', async () => {
const register = vi.fn()
const unregister = vi.fn()
const mountedRegistrationCount = vi.fn()
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/recommend', component: { template: '<div />' } }],
})
await router.push('/recommend')
await router.isReady()
const Page = defineComponent({
setup() {
const activeTab = ref('movie')
const { registerHeaderTab } = useDynamicHeaderTab()
registerHeaderTab({
enableStateRestore: false,
items: [{ tab: 'movie', title: '电影' }],
modelValue: activeTab,
})
onMounted(() => mountedRegistrationCount(register.mock.calls.length))
return {}
},
template: '<div>推荐页</div>',
})
const Host = defineComponent({
components: { Page },
setup() {
const active = ref(true)
provide('registerDynamicHeaderTab', register)
provide('unregisterDynamicHeaderTab', unregister)
return { active }
},
template: `
<button type="button" @click="active = !active">切换</button>
<KeepAlive><Page v-if="active" /></KeepAlive>
`,
})
const wrapper = mount(Host, {
global: {
plugins: [router],
},
})
expect(mountedRegistrationCount).toHaveBeenCalledWith(expect.any(Number))
expect(mountedRegistrationCount.mock.calls[0][0]).toBeGreaterThan(0)
expect(register.mock.calls.at(-1)?.[0].routePath).toBe('/recommend')
await wrapper.get('button').trigger('click')
expect(unregister).toHaveBeenCalledWith('/recommend')
const registrationsBeforeActivation = register.mock.calls.length
await wrapper.get('button').trigger('click')
expect(register.mock.calls.length).toBeGreaterThan(registrationsBeforeActivation)
wrapper.unmount()
})
})
@@ -1,6 +1,7 @@
import { import {
collectGlassOpticalRects, collectGlassOpticalRects,
containsGlassOpticalSurface, containsGlassOpticalSurface,
resolveGlassOpticalSurfaceMode,
setGlassRendererState, setGlassRendererState,
useGlassOpticalInteractionSource, useGlassOpticalInteractionSource,
useGlassOpticalRenderer, useGlassOpticalRenderer,
@@ -45,11 +46,29 @@ vi.mock('three', async importOriginal => {
/** 提供 renderer 单元测试所需的最小 ResizeObserver 实现。 */ /** 提供 renderer 单元测试所需的最小 ResizeObserver 实现。 */
class ResizeObserverMock { class ResizeObserverMock {
/** 断开观察时无需执行额外逻辑。 */ static instances: ResizeObserverMock[] = []
disconnect() {}
/** 测试不需要追踪具体被观察元素。 */ readonly targets = new Set<Element>()
observe() {}
constructor(private readonly callback: ResizeObserverCallback) {
ResizeObserverMock.instances.push(this)
}
/** 断开观察时无需执行额外逻辑。 */
disconnect() {
this.targets.clear()
}
/** 记录观察目标,供尺寸生命周期用例触发回调。 */
observe(target: Element) {
this.targets.add(target)
}
/** 模拟观察目标的内容框发生变化。 */
trigger() {
const entries = [...this.targets].map(target => ({ target }) as ResizeObserverEntry)
this.callback(entries, this as unknown as ResizeObserver)
}
} }
/** 创建带稳定视口边界的光学表面元素。 */ /** 创建带稳定视口边界的光学表面元素。 */
@@ -124,6 +143,7 @@ function dispatchTouchEvent(
} }
beforeEach(() => { beforeEach(() => {
ResizeObserverMock.instances = []
vi.stubGlobal('ResizeObserver', ResizeObserverMock) vi.stubGlobal('ResizeObserver', ResizeObserverMock)
vi.stubGlobal('WebGLRenderingContext', class {}) vi.stubGlobal('WebGLRenderingContext', class {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null) vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
@@ -288,6 +308,19 @@ describe('glass optical surface discovery', () => {
]) ])
}) })
it('inherits the optical mode from a container and permits an explicit child override', () => {
const container = document.createElement('div')
container.dataset.glassOpticalMode = 'static-material'
const inherited = document.createElement('section')
const overridden = document.createElement('section')
overridden.dataset.glassOpticalMode = 'dynamic'
container.append(inherited, overridden)
document.body.append(container)
expect(resolveGlassOpticalSurfaceMode(inherited)).toBe('static-material')
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
})
it('discovers the shared interactive card contract used across routes', () => { it('discovers the shared interactive card contract used across routes', () => {
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 }) const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
surface.style.borderTopLeftRadius = '20px' surface.style.borderTopLeftRadius = '20px'
@@ -389,6 +422,7 @@ describe('glass optical surface discovery', () => {
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1) expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
const renderTargetDisposalsBeforeFirstRelease = renderTargetDispose.mock.calls.length const renderTargetDisposalsBeforeFirstRelease = renderTargetDispose.mock.calls.length
const frameCancellationsBeforeFirstRelease = cancelFrame.mock.calls.length
active.value = false active.value = false
await nextTick() await nextTick()
@@ -397,7 +431,7 @@ describe('glass optical surface discovery', () => {
expect(contextLoss).toHaveBeenCalledTimes(1) expect(contextLoss).toHaveBeenCalledTimes(1)
expect(resizeDisconnect).toHaveBeenCalledTimes(1) expect(resizeDisconnect).toHaveBeenCalledTimes(1)
expect(mutationDisconnect).toHaveBeenCalledTimes(1) expect(mutationDisconnect).toHaveBeenCalledTimes(1)
expect(cancelFrame).toHaveBeenCalledTimes(2) expect(cancelFrame.mock.calls.length).toBeGreaterThan(frameCancellationsBeforeFirstRelease)
addWindowListener.mockClear() addWindowListener.mockClear()
addDocumentListener.mockClear() addDocumentListener.mockClear()
@@ -421,6 +455,7 @@ describe('glass optical surface discovery', () => {
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1) expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
const renderTargetDisposalsBeforeSecondRelease = renderTargetDispose.mock.calls.length const renderTargetDisposalsBeforeSecondRelease = renderTargetDispose.mock.calls.length
const frameCancellationsBeforeSecondRelease = cancelFrame.mock.calls.length
active.value = false active.value = false
await nextTick() await nextTick()
@@ -429,7 +464,7 @@ describe('glass optical surface discovery', () => {
expect(contextLoss).toHaveBeenCalledTimes(2) expect(contextLoss).toHaveBeenCalledTimes(2)
expect(resizeDisconnect).toHaveBeenCalledTimes(2) expect(resizeDisconnect).toHaveBeenCalledTimes(2)
expect(mutationDisconnect).toHaveBeenCalledTimes(2) expect(mutationDisconnect).toHaveBeenCalledTimes(2)
expect(cancelFrame).toHaveBeenCalledTimes(4) expect(cancelFrame.mock.calls.length).toBeGreaterThan(frameCancellationsBeforeSecondRelease)
scope.stop() scope.stop()
}) })
@@ -639,6 +674,7 @@ describe('glass optical surface discovery', () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as { const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{ children: Array<{
material: { material: {
fragmentShader: string
uniforms: { uniforms: {
uPreviousTexture: { value: unknown } uPreviousTexture: { value: unknown }
uTexture: { value: unknown } uTexture: { value: unknown }
@@ -651,6 +687,132 @@ describe('glass optical surface discovery', () => {
expect(uniforms.uPreviousTexture.value).not.toBe(uniforms.uTexture.value) expect(uniforms.uPreviousTexture.value).not.toBe(uniforms.uTexture.value)
expect(uniforms.uTextureMix.value).toBeGreaterThanOrEqual(0) expect(uniforms.uTextureMix.value).toBeGreaterThanOrEqual(0)
expect(uniforms.uTextureMix.value).toBeLessThan(1) expect(uniforms.uTextureMix.value).toBeLessThan(1)
expect(scene.children[0].material.fragmentShader).toContain(
'mix(toneMapWallpaper(previous, viewportUv), toneMapWallpaper(current, viewportUv), uTextureMix)',
)
expect(scene.children[0].material.fragmentShader).not.toContain(
'toneMapWallpaper(mix(previous, current, uTextureMix), viewportUv)',
)
scope.stop()
})
it('reallocates a changed buffer once and renders its stable surface in the same frame', async () => {
const three = await import('three')
let viewportWidth = 1200
vi.spyOn(window, 'innerWidth', 'get').mockImplementation(() => viewportWidth)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(900)
appendOpticalSurface('app-hover-lift-card', { height: 220, width: 320, x: 40, y: 80 })
const setSize = vi.spyOn(three.WebGLRenderer.prototype, 'setSize')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
setSize.mockClear()
render.mockClear()
viewportWidth = 960
window.dispatchEvent(new Event('resize'))
expect(setSize).toHaveBeenCalledOnce()
expect(render).toHaveBeenCalled()
setSize.mockClear()
window.dispatchEvent(new Event('resize'))
expect(setSize).not.toHaveBeenCalled()
scope.stop()
})
it('resizes a scroll buffer when an asynchronous page grows without optical surfaces', async () => {
const three = await import('three')
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
let presentationHeight = 844
const root = document.createElement('div')
const canvas = document.createElement('canvas')
Object.defineProperty(root, 'scrollHeight', {
configurable: true,
get: () => presentationHeight,
})
root.append(canvas)
document.body.append(root)
const setSize = vi.spyOn(three.WebGLRenderer.prototype, 'setSize')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('balanced'),
routeKey: ref('/history'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const observer = ResizeObserverMock.instances.find(instance => instance.targets.has(root))
expect(observer).toBeDefined()
setSize.mockClear()
render.mockClear()
presentationHeight = 5000
observer?.trigger()
expect(setSize).toHaveBeenCalledWith(390, 3072, false)
expect(render).toHaveBeenCalled()
scope.stop()
})
it('writes static-material surfaces into the shader without dynamic optical energy', async () => {
const three = await import('three')
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 320, x: 40, y: 80 })
surface.dataset.glassOpticalMode = 'static-material'
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/plugins'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
fragmentShader: string
uniforms: {
uSurfaceDynamics: { value: number[] }
}
}
}>
}
const material = scene.children[0].material
expect(material.uniforms.uSurfaceDynamics.value[0]).toBe(0)
expect(material.fragmentShader).toContain('uniform float uSurfaceDynamics[8]')
expect(material.fragmentShader).toContain('float surfaceDynamic = uSurfaceDynamics[i]')
scope.stop() scope.stop()
}) })
@@ -1376,7 +1538,7 @@ describe('glass optical surface discovery', () => {
expect(scene.children[0].material.fragmentShader).not.toContain('clamp(uMotion +') expect(scene.children[0].material.fragmentShader).not.toContain('clamp(uMotion +')
expect(scene.children[0].material.fragmentShader).toContain( expect(scene.children[0].material.fragmentShader).toContain(
'materialEnergy = max(materialEnergy, liquidEnergy * rectMask)', 'materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic)',
) )
expect(scene.children[0].material.fragmentShader).toContain('softLimitDynamicRefraction') expect(scene.children[0].material.fragmentShader).toContain('softLimitDynamicRefraction')
expect(scene.children[0].material.fragmentShader).toContain('getContentProtection') expect(scene.children[0].material.fragmentShader).toContain('getContentProtection')
@@ -1384,7 +1546,7 @@ describe('glass optical surface discovery', () => {
expect(scene.children[0].material.fragmentShader).toContain('singleSpecular') expect(scene.children[0].material.fragmentShader).toContain('singleSpecular')
expect(scene.children[0].material.fragmentShader).not.toContain('stableSample') expect(scene.children[0].material.fragmentShader).not.toContain('stableSample')
expect(scene.children[0].material.fragmentShader).not.toContain('broadReflection') expect(scene.children[0].material.fragmentShader).not.toContain('broadReflection')
expect(scene.children[0].material.fragmentShader).toContain('flowSurfaceDetail * 0.38') expect(scene.children[0].material.fragmentShader).toContain('flowSurfaceDetail * dynamicMask * 0.38')
expect(scene.children[0].material.fragmentShader).not.toContain('pointerCurvature') expect(scene.children[0].material.fragmentShader).not.toContain('pointerCurvature')
expect(scene.children[0].material.fragmentShader).not.toContain('wakeCurvature') expect(scene.children[0].material.fragmentShader).not.toContain('wakeCurvature')
expect(scene.children[0].material.fragmentShader).not.toContain('surfaceCurvature') expect(scene.children[0].material.fragmentShader).not.toContain('surfaceCurvature')
+11 -13
View File
@@ -39,7 +39,7 @@ export function useDynamicHeaderTab() {
// 尝试从inject获取 // 尝试从inject获取
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab') const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab') const unregisterDynamicHeaderTab = inject<(routePath?: string) => void>('unregisterDynamicHeaderTab')
/** 注册当前页面的动态头部标签配置。 */ /** 注册当前页面的动态头部标签配置。 */
const registerHeaderTab = (config: { const registerHeaderTab = (config: {
@@ -153,12 +153,12 @@ export function useDynamicHeaderTab() {
// 取消注册函数 // 取消注册函数
const doUnregister = () => { const doUnregister = () => {
if (unregisterDynamicHeaderTab) { if (unregisterDynamicHeaderTab) {
unregisterDynamicHeaderTab() unregisterDynamicHeaderTab(tabConfig.routePath)
} }
} }
// 初始注册(延迟到下个tick,确保路由已经完全切换) // 页签高度必须在页面首帧布局前确定,避免内容先渲染后再整体下移。
nextTick(() => { onBeforeMount(() => {
doRegister() doRegister()
}) })
@@ -166,15 +166,13 @@ export function useDynamicHeaderTab() {
onActivated(() => { onActivated(() => {
// 页面激活时,优先使用当前页面的实际状态,而不是恢复的PWA状态 // 页面激活时,优先使用当前页面的实际状态,而不是恢复的PWA状态
// 这样可以避免从后台切换回来时显示错误的标签页 // 这样可以避免从后台切换回来时显示错误的标签页
nextTick(() => { // 确保使用当前页面的实际modelValue,不受PWA状态恢复影响
// 确保使用当前页面的实际modelValue,不受PWA状态恢复影响 tabConfig.modelValue = config.modelValue.value
tabConfig.modelValue = config.modelValue.value // 同步当前状态到PWA存储,确保状态一致性
// 同步当前状态到PWA存储,确保状态一致性 if (pwaTabState && config.modelValue.value) {
if (pwaTabState && config.modelValue.value) { pwaTabState.activeTab.value = config.modelValue.value
pwaTabState.activeTab.value = config.modelValue.value }
} doRegister()
doRegister()
})
}) })
// 处理页面失活时取消注册(支持keep-alive缓存的页面) // 处理页面失活时取消注册(支持keep-alive缓存的页面)
+81 -32
View File
@@ -43,6 +43,7 @@ import {
type GlassOpticalQuality, type GlassOpticalQuality,
type GlassOpticalRect, type GlassOpticalRect,
type GlassOpticalSurfaceCandidate, type GlassOpticalSurfaceCandidate,
type GlassOpticalSurfaceMode,
type GlassOpticalSurfaceSlot, type GlassOpticalSurfaceSlot,
} from '@/utils/glassOptics' } from '@/utils/glassOptics'
import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer' import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer'
@@ -166,6 +167,7 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
uRectCount: IUniform<number> uRectCount: IUniform<number>
uRects: IUniform<Vector4[]> uRects: IUniform<Vector4[]>
uSurfaceWeights: IUniform<number[]> uSurfaceWeights: IUniform<number[]>
uSurfaceDynamics: IUniform<number[]>
uPreviousTexture: IUniform<Texture | null> uPreviousTexture: IUniform<Texture | null>
uTexture: IUniform<Texture | null> uTexture: IUniform<Texture | null>
uTextureMix: IUniform<number> uTextureMix: IUniform<number>
@@ -237,7 +239,9 @@ interface UseGlassOpticalRendererOptions {
wallpaperUrl: MaybeRefOrGetter<string> wallpaperUrl: MaybeRefOrGetter<string>
} }
type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement> type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement> & {
mode: GlassOpticalSurfaceMode
}
interface PreparedWallpaperTexture { interface PreparedWallpaperTexture {
/** 是否包含可采样的真实壁纸,而非程序化回退纹理。 */ /** 是否包含可采样的真实壁纸,而非程序化回退纹理。 */
@@ -367,6 +371,7 @@ uniform float uReflectionStrength;
uniform vec4 uRects[8]; uniform vec4 uRects[8];
uniform vec4 uRadii[8]; uniform vec4 uRadii[8];
uniform float uSurfaceWeights[8]; uniform float uSurfaceWeights[8];
uniform float uSurfaceDynamics[8];
uniform int uRectCount; uniform int uRectCount;
uniform float uAppearance; uniform float uAppearance;
uniform vec3 uTintColor; uniform vec3 uTintColor;
@@ -468,7 +473,10 @@ vec3 sampleWallpaper(vec2 uv) {
vec3 previous = texture2D(uPreviousTexture, previousUv).rgb; vec3 previous = texture2D(uPreviousTexture, previousUv).rgb;
vec3 current = texture2D(uTexture, uv).rgb; vec3 current = texture2D(uTexture, uv).rgb;
return toneMapWallpaper(mix(previous, current, uTextureMix), viewportUv); if (uTextureMix <= 0.001) return toneMapWallpaper(previous, viewportUv);
if (uTextureMix >= 0.999) return toneMapWallpaper(current, viewportUv);
return mix(toneMapWallpaper(previous, viewportUv), toneMapWallpaper(current, viewportUv), uTextureMix);
} }
vec3 sampleChromatic(vec2 uv, float separation) { vec3 sampleChromatic(vec2 uv, float separation) {
@@ -541,6 +549,7 @@ void main() {
float topPrism = 0.0; float topPrism = 0.0;
float backlightAbsorption = 0.0; float backlightAbsorption = 0.0;
float materialEnergy = 0.0; float materialEnergy = 0.0;
float dynamicMask = 0.0;
vec2 staticRefraction = vec2(0.0); vec2 staticRefraction = vec2(0.0);
vec2 dynamicRefraction = vec2(0.0); vec2 dynamicRefraction = vec2(0.0);
vec2 wakeDirection = length(uWakeDirection) > 0.0001 ? normalize(uWakeDirection) : vec2(0.0, -1.0); vec2 wakeDirection = length(uWakeDirection) > 0.0001 ? normalize(uWakeDirection) : vec2(0.0, -1.0);
@@ -598,6 +607,7 @@ void main() {
if (i >= uRectCount) break; if (i >= uRectCount) break;
vec4 rect = uRects[i]; vec4 rect = uRects[i];
float surfaceDynamic = uSurfaceDynamics[i];
vec2 local = (vUv - rect.xy) / rect.zw; vec2 local = (vUv - rect.xy) / rect.zw;
float rectMask = roundedRectMask(local, rect.zw * uPresentationSize, uRadii[i]) * uSurfaceWeights[i]; float rectMask = roundedRectMask(local, rect.zw * uPresentationSize, uRadii[i]) * uSurfaceWeights[i];
if (rectMask <= 0.0) continue; if (rectMask <= 0.0) continue;
@@ -666,8 +676,8 @@ void main() {
)) * )) *
uMotion * uMotion *
mix(1.0, 1.24, uMotionExpansion); mix(1.0, 1.24, uMotionExpansion);
float localCaustic = singleSpecular * rectMask; float localCaustic = singleSpecular * rectMask * surfaceDynamic;
staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask; staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask * surfaceDynamic;
vec2 sampleTranslation = vec2 sampleTranslation =
uPointerVelocity * uPointerVelocity *
mix(0.055, 0.075, uQuality) * mix(0.055, 0.075, uQuality) *
@@ -679,13 +689,14 @@ void main() {
trailRefraction * trailStrength + trailRefraction * trailStrength +
temporalFlow * temporalStrength + temporalFlow * temporalStrength +
wakeRefraction wakeRefraction
) * rectMask; ) * rectMask * surfaceDynamic;
edge = max(edge, edgeResponse * rectMask); edge = max(edge, edgeResponse * rectMask * surfaceDynamic);
caustic = max(caustic, localCaustic); caustic = max(caustic, localCaustic);
directionalReflection = max(directionalReflection, localDirectionalReflection); directionalReflection = max(directionalReflection, localDirectionalReflection);
topPrism = max(topPrism, localTopPrism); topPrism = max(topPrism, localTopPrism);
backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption); backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption);
materialEnergy = max(materialEnergy, liquidEnergy * rectMask); materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic);
dynamicMask = max(dynamicMask, rectMask * surfaceDynamic);
mask = max(mask, rectMask); mask = max(mask, rectMask);
} }
@@ -707,7 +718,7 @@ void main() {
( (
0.82 + 0.82 +
materialEnergy * mix(0.28, 0.76, uMotionExpansion) + materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
flowSurfaceDetail * 0.38 flowSurfaceDetail * dynamicMask * 0.38
); );
vec3 diffused; vec3 diffused;
if (uQuality > 0.5) { if (uQuality > 0.5) {
@@ -844,6 +855,13 @@ function isVisibleSurface(element: HTMLElement, bounds: DOMRect) {
) )
} }
/** 从最近的组件边界读取模式;未声明的现有表面保持完整动态行为。 */
export function resolveGlassOpticalSurfaceMode(element: HTMLElement): GlassOpticalSurfaceMode {
const value = element.closest<HTMLElement>('[data-glass-optical-mode]')?.dataset.glassOpticalMode
return value === 'static-material' ? 'static-material' : 'dynamic'
}
/** 读取全部可见视觉表面,并保留 DOM 元素作为 renderer 生命周期内的稳定身份。 */ /** 读取全部可见视觉表面,并保留 DOM 元素作为 renderer 生命周期内的稳定身份。 */
function collectGlassOpticalSurfaceDescriptors( function collectGlassOpticalSurfaceDescriptors(
viewportWidth: number, viewportWidth: number,
@@ -879,6 +897,7 @@ function collectGlassOpticalSurfaceDescriptors(
candidates.push({ candidates.push({
key: element, key: element,
mode: resolveGlassOpticalSurfaceMode(element),
rect: { rect: {
height: bounds.height, height: bounds.height,
radii: [...readBorderRadii(element)] as GlassCornerRadii, radii: [...readBorderRadii(element)] as GlassCornerRadii,
@@ -899,12 +918,13 @@ function collectGlassOpticalSurfaceDescriptors(
const { rect } = candidate const { rect } = candidate
const nested = selected.some( const nested = selected.some(
parent => parent =>
parent.mode === candidate.mode &&
rect.x >= parent.rect.x && rect.x >= parent.rect.x &&
rect.y >= parent.rect.y && rect.y >= parent.rect.y &&
rect.x + rect.width <= parent.rect.x + parent.rect.width && rect.x + rect.width <= parent.rect.x + parent.rect.width &&
rect.y + rect.height <= parent.rect.y + parent.rect.height, rect.y + rect.height <= parent.rect.y + parent.rect.height,
) )
if (!nested) selected.push({ key: candidate.key, rect }) if (!nested) selected.push({ key: candidate.key, mode: candidate.mode, rect })
} }
return selected return selected
@@ -979,7 +999,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let activeTouchIdentifier: number | null = null let activeTouchIdentifier: number | null = null
let pendingFlowInjection = 0 let pendingFlowInjection = 0
let interactionAnimating = false let interactionAnimating = false
let surfaceResizeTimer: number | null = null
let scrollAnimationFrame: number | null = null let scrollAnimationFrame: number | null = null
let scrollDirty = false let scrollDirty = false
let scrollSurfaceRefreshPending = false let scrollSurfaceRefreshPending = false
@@ -1030,6 +1049,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
return { x: clientX + window.scrollX, y: clientY + window.scrollY } return { x: clientX + window.scrollX, y: clientY + window.scrollY }
} }
/** scroll 呈现层必须跟随页面异步撑高,即使页面内没有可发现的光学表面。 */
function observeResizeTargets(reset = true) {
if (reset) resizeObserver?.disconnect()
if (presentationSpace === 'scroll') {
const presentationRoot = options.canvas.value?.parentElement
if (presentationRoot) resizeObserver?.observe(presentationRoot)
}
for (const element of observedSurfaces) resizeObserver?.observe(element)
}
function cancelScheduledFrame() { function cancelScheduledFrame() {
if (animationFrame === null) return if (animationFrame === null) return
@@ -1237,6 +1266,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const uniformRects = resources.uniforms.uRects.value const uniformRects = resources.uniforms.uRects.value
const uniformRadii = resources.uniforms.uRadii.value const uniformRadii = resources.uniforms.uRadii.value
const uniformWeights = resources.uniforms.uSurfaceWeights.value const uniformWeights = resources.uniforms.uSurfaceWeights.value
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
const transitionWeights = outgoingSurface const transitionWeights = outgoingSurface
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS) ? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
: { incoming: 1, outgoing: 0 } : { incoming: 1, outgoing: 0 }
@@ -1256,6 +1286,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
: slot : slot
? 1 ? 1
: 0 : 0
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
} }
resources.uniforms.uRectCount.value = normalized.length resources.uniforms.uRectCount.value = normalized.length
@@ -1296,9 +1327,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
nextObservedSurfaces.some((element, index) => element !== observedSurfaces[index]) nextObservedSurfaces.some((element, index) => element !== observedSurfaces[index])
if (observedSurfacesChanged) { if (observedSurfacesChanged) {
resizeObserver?.disconnect()
observedSurfaces = nextObservedSurfaces observedSurfaces = nextObservedSurfaces
for (const element of observedSurfaces) resizeObserver?.observe(element) observeResizeTargets()
} }
if (scheduleRender) scheduleFrame() if (scheduleRender) scheduleFrame()
} }
@@ -1349,14 +1379,19 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
surfaceStabilityFrame = requestAnimationFrame(sample) surfaceStabilityFrame = requestAnimationFrame(sample)
} }
function scheduleSurfaceResizeUpdate() { /** ResizeObserver 在浏览器绘制前提交新尺寸,避免 CSS 与 WebGL buffer 跨帧失配。 */
if (surfaceResizeTimer !== null) window.clearTimeout(surfaceResizeTimer) function handleSurfaceResize(entries: ResizeObserverEntry[]) {
if (!resources) return
surfaceResizeTimer = window.setTimeout(() => { const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
surfaceResizeTimer = null if (presentationRoot && entries.some(entry => entry.target === presentationRoot)) {
if (presentationSpace === 'scroll') resizeRenderer() resizeRenderer()
else scheduleSurfaceUpdate() return
}, 160) }
const timestamp = performance.now()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
} }
/** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */ /** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */
@@ -1488,9 +1523,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
profile.bufferQuality, profile.bufferQuality,
window.devicePixelRatio, window.devicePixelRatio,
) )
const bufferChanged = presentationBufferWidth !== buffer.width || presentationBufferHeight !== buffer.height
const presentationChanged =
resources.uniforms.uPresentationSize.value.x !== presentation.width ||
resources.uniforms.uPresentationSize.value.y !== presentation.height
presentationBufferHeight = buffer.height presentationBufferHeight = buffer.height
presentationBufferWidth = buffer.width presentationBufferWidth = buffer.width
resources.renderer.setSize(buffer.width, buffer.height, false) if (bufferChanged) resources.renderer.setSize(buffer.width, buffer.height, false)
resources.uniforms.uVisibleViewportSize.value.set(viewportWidth, viewportHeight) resources.uniforms.uVisibleViewportSize.value.set(viewportWidth, viewportHeight)
resources.uniforms.uPresentationSize.value.set(presentation.width, presentation.height) resources.uniforms.uPresentationSize.value.set(presentation.width, presentation.height)
resources.uniforms.uScrollOffset.value.set( resources.uniforms.uScrollOffset.value.set(
@@ -1500,13 +1539,20 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (flowResources) { if (flowResources) {
const flowWidth = Math.max(96, Math.round(buffer.width * FLOW_BUFFER_SCALE)) const flowWidth = Math.max(96, Math.round(buffer.width * FLOW_BUFFER_SCALE))
const flowHeight = Math.max(96, Math.round(buffer.height * FLOW_BUFFER_SCALE)) const flowHeight = Math.max(96, Math.round(buffer.height * FLOW_BUFFER_SCALE))
flowResources.readTarget.setSize(flowWidth, flowHeight) const flowBufferChanged =
flowResources.writeTarget.setSize(flowWidth, flowHeight) flowResources.readTarget.width !== flowWidth || flowResources.readTarget.height !== flowHeight
if (flowBufferChanged) {
flowResources.readTarget.setSize(flowWidth, flowHeight)
flowResources.writeTarget.setSize(flowWidth, flowHeight)
}
flowResources.uniforms.uTexelSize.value.set(1 / flowWidth, 1 / flowHeight) flowResources.uniforms.uTexelSize.value.set(1 / flowWidth, 1 / flowHeight)
flowResources.uniforms.uViewportAspect.value = viewportWidth / Math.max(viewportHeight, 1) flowResources.uniforms.uViewportAspect.value = viewportWidth / Math.max(viewportHeight, 1)
} }
syncCoverScale(viewportWidth, viewportHeight) syncCoverScale(viewportWidth, viewportHeight)
scheduleSurfaceUpdate() updateSurfaceUniforms(performance.now(), false)
// buffer 重分配或归一化呈现尺寸变化都必须在当前绘制周期提交稳定画面。
if (bufferChanged || presentationChanged) renderFrame(performance.now(), false)
else scheduleSurfaceUpdate()
} }
function profileRequiresTextureReload( function profileRequiresTextureReload(
@@ -1749,7 +1795,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
lastPointerX = clientX lastPointerX = clientX
lastPointerY = clientY lastPointerY = clientY
lastPointerAt = timestamp lastPointerAt = timestamp
if (!resources || !surface) return if (!resources || !surface || surface.mode === 'static-material') return
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
const surfaceChanged = activateInteractionSurface(surface.key, timestamp, reducedMotion) const surfaceChanged = activateInteractionSurface(surface.key, timestamp, reducedMotion)
@@ -1833,7 +1879,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const point = getPresentationPoint(touch.clientX, touch.clientY) const point = getPresentationPoint(touch.clientX, touch.clientY)
const presentation = getPresentationSize() const presentation = getPresentationSize()
const surface = findInteractionSurface(point.x, point.y) const surface = findInteractionSurface(point.x, point.y)
if (resources && surface) { if (resources && surface?.mode === 'dynamic') {
activateInteractionSurface(surface.key, timestamp, matchMedia('(prefers-reduced-motion: reduce)').matches) activateInteractionSurface(surface.key, timestamp, matchMedia('(prefers-reduced-motion: reduce)').matches)
snapPointer(point.x / Math.max(presentation.width, 1), 1 - point.y / Math.max(presentation.height, 1)) snapPointer(point.x / Math.max(presentation.width, 1), 1 - point.y / Math.max(presentation.height, 1))
scheduleFrame() scheduleFrame()
@@ -2018,14 +2064,20 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function setupObservers() { function setupObservers() {
resizeObserver = new ResizeObserver(scheduleSurfaceResizeUpdate) resizeObserver = new ResizeObserver(handleSurfaceResize)
observeResizeTargets(false)
const observedMutationRoots = new Set<Node>() const observedMutationRoots = new Set<Node>()
function observeMutationRoot(root: Node | null, subtree: boolean) { function observeMutationRoot(root: Node | null, subtree: boolean) {
if (!root || observedMutationRoots.has(root)) return if (!root || observedMutationRoots.has(root)) return
observedMutationRoots.add(root) observedMutationRoots.add(root)
surfaceMutationObserver?.observe(root, { childList: true, subtree }) surfaceMutationObserver?.observe(root, {
attributeFilter: ['data-glass-optical-mode'],
attributes: true,
childList: true,
subtree,
})
} }
surfaceMutationObserver = new MutationObserver(mutations => { surfaceMutationObserver = new MutationObserver(mutations => {
@@ -2104,10 +2156,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
cancelAnimationFrame(surfaceStabilityFrame) cancelAnimationFrame(surfaceStabilityFrame)
surfaceStabilityFrame = null surfaceStabilityFrame = null
} }
if (surfaceResizeTimer !== null) {
window.clearTimeout(surfaceResizeTimer)
surfaceResizeTimer = null
}
removeEvents() removeEvents()
resizeObserver?.disconnect() resizeObserver?.disconnect()
resizeObserver = null resizeObserver = null
@@ -2393,6 +2441,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
uRectCount: { value: 0 }, uRectCount: { value: 0 },
uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) }, uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) }, uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
uPreviousTexture: { value: null }, uPreviousTexture: { value: null },
uTexture: { value: null }, uTexture: { value: null },
uTextureMix: { value: 1 }, uTextureMix: { value: 1 },
@@ -2578,7 +2627,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
() => toValue(options.routeKey), () => toValue(options.routeKey),
async (routeKey, previousRouteKey) => { async (routeKey, previousRouteKey) => {
const previousProfile = getRenderProfile(previousRouteKey ?? '') const previousProfile = getRenderProfile(previousRouteKey ?? '')
await nextTick()
if (resources) { if (resources) {
const nextProfile = getRenderProfile(routeKey) const nextProfile = getRenderProfile(routeKey)
resizeRenderer() resizeRenderer()
@@ -2590,6 +2638,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
scheduleSurfaceUpdate() scheduleSurfaceUpdate()
}, },
{ flush: 'post' },
) )
onScopeDispose(() => { onScopeDispose(() => {
@@ -156,8 +156,10 @@ const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
applyPendingHorizontalTab() applyPendingHorizontalTab()
} }
/** 取消当前页面注册的动态标签页。 */ /** 仅注销仍由指定路由持有的动态标签,避免相邻页面生命周期互相覆盖。 */
const unregisterDynamicHeaderTab = () => { const unregisterDynamicHeaderTab = (routePath?: string) => {
if (routePath && dynamicHeaderTab.value?.routePath !== routePath) return
dynamicHeaderTab.value = null dynamicHeaderTab.value = null
} }
@@ -844,5 +846,4 @@ onMounted(async () => {
align-items: center; align-items: center;
margin-inline-start: auto; margin-inline-start: auto;
} }
</style> </style>
+4 -12
View File
@@ -26,6 +26,10 @@ const downloaderItems = computed(() => {
// 使用动态标签页 // 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab() const { registerHeaderTab } = useDynamicHeaderTab()
registerHeaderTab({
items: downloaderItems,
modelValue: activeTab,
})
// 调用API查询下载器设置 // 调用API查询下载器设置
async function loadDownloaderSetting() { async function loadDownloaderSetting() {
@@ -38,24 +42,12 @@ async function loadDownloaderSetting() {
} }
} }
// 注册动态标签页
const registerTabs = () => {
if (downloaderItems.value.length > 0) {
registerHeaderTab({
items: downloaderItems,
modelValue: activeTab,
})
}
}
onMounted(async () => { onMounted(async () => {
await loadDownloaderSetting() await loadDownloaderSetting()
registerTabs()
}) })
useKeepAliveRefresh(async () => { useKeepAliveRefresh(async () => {
await loadDownloaderSetting() await loadDownloaderSetting()
registerTabs()
}) })
</script> </script>
+3 -1
View File
@@ -3,5 +3,7 @@ import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
</script> </script>
<template> <template>
<FileBrowserView /> <div data-glass-optical-mode="static-material">
<FileBrowserView />
</div>
</template> </template>
+1 -1
View File
@@ -3,7 +3,7 @@ import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'
</script> </script>
<template> <template>
<div> <div data-glass-optical-mode="static-material">
<TransferHistoryView /> <TransferHistoryView />
</div> </div>
</template> </template>
+1 -1
View File
@@ -42,7 +42,7 @@ watch(
</script> </script>
<template> <template>
<div class="plugin-app-page"> <div class="plugin-app-page" data-glass-optical-mode="static-material">
<VAlert v-if="loadError" type="error" class="ma-4" title="组件加载错误"> <VAlert v-if="loadError" type="error" class="ma-4" title="组件加载错误">
无法加载插件全页组件多入口时请暴露 AppPage AppPage{Pascal}见文档并确认插件已启用 无法加载插件全页组件多入口时请暴露 AppPage AppPage{Pascal}见文档并确认插件已启用
</VAlert> </VAlert>
@@ -1,5 +1,6 @@
import { import {
BACKGROUND_ROTATION_GRACE_MS, BACKGROUND_ROTATION_GRACE_MS,
createBackgroundCandidateOrderResolver,
findFirstAvailableBackground, findFirstAvailableBackground,
preloadBackgroundRotationImages, preloadBackgroundRotationImages,
shouldAllowBackgroundRotation, shouldAllowBackgroundRotation,
@@ -27,6 +28,27 @@ describe('background rotation lifecycle', () => {
}) })
}) })
describe('createBackgroundCandidateOrderResolver', () => {
it('shuffles a source list without mutating the backend response', () => {
const urls = ['one.jpg', 'two.jpg', 'three.jpg']
const resolveOrder = createBackgroundCandidateOrderResolver(() => 0)
expect(resolveOrder(urls)).toEqual(['two.jpg', 'three.jpg', 'one.jpg'])
expect(urls).toEqual(['one.jpg', 'two.jpg', 'three.jpg'])
})
it('keeps the same order across retries and reshuffles only after the source list changes', () => {
const random = vi.fn().mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(0.5)
const resolveOrder = createBackgroundCandidateOrderResolver(random)
const firstOrder = resolveOrder(['one.jpg', 'two.jpg', 'three.jpg'])
expect(resolveOrder(['one.jpg', 'two.jpg', 'three.jpg'])).toEqual(firstOrder)
expect(random).toHaveBeenCalledTimes(2)
expect(resolveOrder(['one.jpg', 'two.jpg'])).toEqual(['one.jpg', 'two.jpg'])
expect(random).toHaveBeenCalledTimes(3)
})
})
describe('preloadBackgroundRotationImages', () => { describe('preloadBackgroundRotationImages', () => {
it('does not let an unused optical texture block the visible wallpaper', async () => { it('does not let an unused optical texture block the visible wallpaper', async () => {
const preload = vi.fn(async (url: string) => url === 'display.jpg') const preload = vi.fn(async (url: string) => url === 'display.jpg')
+27 -2
View File
@@ -3,11 +3,36 @@ import type { AppActivityState } from '@/utils/appActivityLifecycle'
/** 壁纸在窗口失焦后继续轮换的最长时间,交互 renderer 仍由应用生命周期独立暂停。 */ /** 壁纸在窗口失焦后继续轮换的最长时间,交互 renderer 仍由应用生命周期独立暂停。 */
export const BACKGROUND_ROTATION_GRACE_MS = 60_000 export const BACKGROUND_ROTATION_GRACE_MS = 60_000
type RandomSource = () => number
/** 壁纸轮换只在前台活动或失焦宽限期内运行,系统减少动态效果时始终停止。 */ /** 壁纸轮换只在前台活动或失焦宽限期内运行,系统减少动态效果时始终停止。 */
export function shouldAllowBackgroundRotation(state: AppActivityState, graceActive: boolean, reducedMotion: boolean) { export function shouldAllowBackgroundRotation(state: AppActivityState, graceActive: boolean, reducedMotion: boolean) {
return !reducedMotion && (state === 'active' || graceActive) return !reducedMotion && (state === 'active' || graceActive)
} }
/**
* 为每个前端页面生命周期生成稳定的随机候选顺序;相同来源列表在重试和状态恢复时不会再次洗牌。
*/
export function createBackgroundCandidateOrderResolver(random: RandomSource = Math.random) {
let sourceUrls: string[] | null = null
let orderedUrls: string[] = []
return (urls: string[]) => {
const sourceChanged =
!sourceUrls || sourceUrls.length !== urls.length || sourceUrls.some((url, index) => url !== urls[index])
if (!sourceChanged) return [...orderedUrls]
sourceUrls = [...urls]
orderedUrls = [...urls]
for (let index = orderedUrls.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1))
;[orderedUrls[index], orderedUrls[swapIndex]] = [orderedUrls[swapIndex], orderedUrls[index]]
}
return [...orderedUrls]
}
}
interface BackgroundRotationImagePreloadOptions { interface BackgroundRotationImagePreloadOptions {
/** 外层背景实际显示的壁纸地址。 */ /** 外层背景实际显示的壁纸地址。 */
displayUrl: string displayUrl: string
@@ -18,7 +43,7 @@ interface BackgroundRotationImagePreloadOptions {
} }
interface FirstAvailableBackgroundOptions { interface FirstAvailableBackgroundOptions {
/** 保持后端返回顺序的候选壁纸。 */ /** 当前页面生命周期已确定顺序的候选壁纸。 */
urls: string[] urls: string[]
/** 当前加载批次仍可提交时返回 true。 */ /** 当前加载批次仍可提交时返回 true。 */
canContinue: () => boolean canContinue: () => boolean
@@ -26,7 +51,7 @@ interface FirstAvailableBackgroundOptions {
preload: (url: string) => Promise<boolean> preload: (url: string) => Promise<boolean>
} }
/** 按来源顺序寻找首张可用壁纸,单项失败或过期批次不会提交可见状态。 */ /** 按当前候选顺序寻找首张可用壁纸,单项失败或过期批次不会提交可见状态。 */
export async function findFirstAvailableBackground(options: FirstAvailableBackgroundOptions) { export async function findFirstAvailableBackground(options: FirstAvailableBackgroundOptions) {
for (let index = 0; index < options.urls.length; index += 1) { for (let index = 0; index < options.urls.length; index += 1) {
if (!options.canContinue()) return null if (!options.canContinue()) return null
+5
View File
@@ -72,9 +72,14 @@ export interface GlassOpticalSpringState {
velocity: number velocity: number
} }
/** 光学表面在共享 renderer 中采用的动态响应合同。 */
export type GlassOpticalSurfaceMode = 'dynamic' | 'static-material'
export interface GlassOpticalSurfaceCandidate<TKey> { export interface GlassOpticalSurfaceCandidate<TKey> {
/** renderer 生命周期内稳定的表面身份。 */ /** renderer 生命周期内稳定的表面身份。 */
key: TKey key: TKey
/** 表面使用完整动态光学,或只保留稳定材质能量。 */
mode?: GlassOpticalSurfaceMode
/** 表面的当前视口几何。 */ /** 表面的当前视口几何。 */
rect: GlassOpticalRect rect: GlassOpticalRect
} }