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

View File

@@ -322,7 +322,21 @@ const emit = defineEmits(['action'])
`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。
@@ -357,7 +371,7 @@ async function subscribeMedia(mediaInfo: Record<string, unknown>) {
`success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。
### 5.7 调用主应用 Toast
### 5.8 调用主应用 Toast
`Page``Config``Dashboard``AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:

View File

@@ -29,6 +29,7 @@ import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composab
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import {
BACKGROUND_ROTATION_GRACE_MS,
createBackgroundCandidateOrderResolver,
findFirstAvailableBackground,
preloadBackgroundRotationImages,
shouldAllowBackgroundRotation,
@@ -87,6 +88,7 @@ const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false)
const backgroundCrossfadeStartedAt = ref(0)
const pendingOpticalBackgroundImage = ref('')
const resolveBackgroundCandidateOrder = createBackgroundCandidateOrderResolver()
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
const preferredMotion = usePreferredReducedMotion()
const backgroundRotationGraceActive = ref(false)
@@ -718,7 +720,7 @@ async function removeLoadingWithStateCheck() {
async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
const maxRetries = 3
try {
const images = await fetchBackgroundImages()
const images = resolveBackgroundCandidateOrder(await fetchBackgroundImages())
if (loadVersion !== backgroundLoadVersion) return
const firstAvailableIndex = await findFirstAvailableBackground({

View File

@@ -173,7 +173,12 @@ onBeforeMount(async () => {
<template>
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
<!-- 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')" />
<VDivider />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
@@ -208,7 +213,7 @@ onBeforeMount(async () => {
</VCardActions>
</VCard>
<!-- 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">
<component
:is="dynamicComponent"

View File

@@ -136,7 +136,12 @@ onMounted(() => {
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<!-- 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')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else class="min-h-40">
@@ -158,7 +163,7 @@ onMounted(() => {
/>
</VCard>
<!-- 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">
<component
:is="dynamicComponent"

View File

@@ -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()
})
})

View File

@@ -1,6 +1,7 @@
import {
collectGlassOpticalRects,
containsGlassOpticalSurface,
resolveGlassOpticalSurfaceMode,
setGlassRendererState,
useGlassOpticalInteractionSource,
useGlassOpticalRenderer,
@@ -45,11 +46,29 @@ vi.mock('three', async importOriginal => {
/** 提供 renderer 单元测试所需的最小 ResizeObserver 实现。 */
class ResizeObserverMock {
/** 断开观察时无需执行额外逻辑。 */
disconnect() {}
static instances: ResizeObserverMock[] = []
/** 测试不需要追踪具体被观察元素。 */
observe() {}
readonly targets = new Set<Element>()
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(() => {
ResizeObserverMock.instances = []
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
vi.stubGlobal('WebGLRenderingContext', class {})
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', () => {
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
surface.style.borderTopLeftRadius = '20px'
@@ -389,6 +422,7 @@ describe('glass optical surface discovery', () => {
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
const renderTargetDisposalsBeforeFirstRelease = renderTargetDispose.mock.calls.length
const frameCancellationsBeforeFirstRelease = cancelFrame.mock.calls.length
active.value = false
await nextTick()
@@ -397,7 +431,7 @@ describe('glass optical surface discovery', () => {
expect(contextLoss).toHaveBeenCalledTimes(1)
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
expect(cancelFrame).toHaveBeenCalledTimes(2)
expect(cancelFrame.mock.calls.length).toBeGreaterThan(frameCancellationsBeforeFirstRelease)
addWindowListener.mockClear()
addDocumentListener.mockClear()
@@ -421,6 +455,7 @@ describe('glass optical surface discovery', () => {
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
const renderTargetDisposalsBeforeSecondRelease = renderTargetDispose.mock.calls.length
const frameCancellationsBeforeSecondRelease = cancelFrame.mock.calls.length
active.value = false
await nextTick()
@@ -429,7 +464,7 @@ describe('glass optical surface discovery', () => {
expect(contextLoss).toHaveBeenCalledTimes(2)
expect(resizeDisconnect).toHaveBeenCalledTimes(2)
expect(mutationDisconnect).toHaveBeenCalledTimes(2)
expect(cancelFrame).toHaveBeenCalledTimes(4)
expect(cancelFrame.mock.calls.length).toBeGreaterThan(frameCancellationsBeforeSecondRelease)
scope.stop()
})
@@ -639,6 +674,7 @@ describe('glass optical surface discovery', () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
fragmentShader: string
uniforms: {
uPreviousTexture: { 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.uTextureMix.value).toBeGreaterThanOrEqual(0)
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()
})
@@ -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).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('getContentProtection')
@@ -1384,7 +1546,7 @@ describe('glass optical surface discovery', () => {
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('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('wakeCurvature')
expect(scene.children[0].material.fragmentShader).not.toContain('surfaceCurvature')

View File

@@ -39,7 +39,7 @@ export function useDynamicHeaderTab() {
// 尝试从inject获取
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
const unregisterDynamicHeaderTab = inject<(routePath?: string) => void>('unregisterDynamicHeaderTab')
/** 注册当前页面的动态头部标签配置。 */
const registerHeaderTab = (config: {
@@ -153,12 +153,12 @@ export function useDynamicHeaderTab() {
// 取消注册函数
const doUnregister = () => {
if (unregisterDynamicHeaderTab) {
unregisterDynamicHeaderTab()
unregisterDynamicHeaderTab(tabConfig.routePath)
}
}
// 初始注册延迟到下个tick确保路由已经完全切换
nextTick(() => {
// 页签高度必须在页面首帧布局前确定,避免内容先渲染后再整体下移。
onBeforeMount(() => {
doRegister()
})
@@ -166,15 +166,13 @@ export function useDynamicHeaderTab() {
onActivated(() => {
// 页面激活时优先使用当前页面的实际状态而不是恢复的PWA状态
// 这样可以避免从后台切换回来时显示错误的标签页
nextTick(() => {
// 确保使用当前页面的实际modelValue不受PWA状态恢复影响
tabConfig.modelValue = config.modelValue.value
// 同步当前状态到PWA存储确保状态一致性
if (pwaTabState && config.modelValue.value) {
pwaTabState.activeTab.value = config.modelValue.value
}
doRegister()
})
// 确保使用当前页面的实际modelValue不受PWA状态恢复影响
tabConfig.modelValue = config.modelValue.value
// 同步当前状态到PWA存储确保状态一致性
if (pwaTabState && config.modelValue.value) {
pwaTabState.activeTab.value = config.modelValue.value
}
doRegister()
})
// 处理页面失活时取消注册支持keep-alive缓存的页面

View File

@@ -43,6 +43,7 @@ import {
type GlassOpticalQuality,
type GlassOpticalRect,
type GlassOpticalSurfaceCandidate,
type GlassOpticalSurfaceMode,
type GlassOpticalSurfaceSlot,
} from '@/utils/glassOptics'
import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer'
@@ -166,6 +167,7 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
uRectCount: IUniform<number>
uRects: IUniform<Vector4[]>
uSurfaceWeights: IUniform<number[]>
uSurfaceDynamics: IUniform<number[]>
uPreviousTexture: IUniform<Texture | null>
uTexture: IUniform<Texture | null>
uTextureMix: IUniform<number>
@@ -237,7 +239,9 @@ interface UseGlassOpticalRendererOptions {
wallpaperUrl: MaybeRefOrGetter<string>
}
type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement>
type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement> & {
mode: GlassOpticalSurfaceMode
}
interface PreparedWallpaperTexture {
/** 是否包含可采样的真实壁纸,而非程序化回退纹理。 */
@@ -367,6 +371,7 @@ uniform float uReflectionStrength;
uniform vec4 uRects[8];
uniform vec4 uRadii[8];
uniform float uSurfaceWeights[8];
uniform float uSurfaceDynamics[8];
uniform int uRectCount;
uniform float uAppearance;
uniform vec3 uTintColor;
@@ -468,7 +473,10 @@ vec3 sampleWallpaper(vec2 uv) {
vec3 previous = texture2D(uPreviousTexture, previousUv).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) {
@@ -541,6 +549,7 @@ void main() {
float topPrism = 0.0;
float backlightAbsorption = 0.0;
float materialEnergy = 0.0;
float dynamicMask = 0.0;
vec2 staticRefraction = vec2(0.0);
vec2 dynamicRefraction = vec2(0.0);
vec2 wakeDirection = length(uWakeDirection) > 0.0001 ? normalize(uWakeDirection) : vec2(0.0, -1.0);
@@ -598,6 +607,7 @@ void main() {
if (i >= uRectCount) break;
vec4 rect = uRects[i];
float surfaceDynamic = uSurfaceDynamics[i];
vec2 local = (vUv - rect.xy) / rect.zw;
float rectMask = roundedRectMask(local, rect.zw * uPresentationSize, uRadii[i]) * uSurfaceWeights[i];
if (rectMask <= 0.0) continue;
@@ -666,8 +676,8 @@ void main() {
)) *
uMotion *
mix(1.0, 1.24, uMotionExpansion);
float localCaustic = singleSpecular * rectMask;
staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask;
float localCaustic = singleSpecular * rectMask * surfaceDynamic;
staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask * surfaceDynamic;
vec2 sampleTranslation =
uPointerVelocity *
mix(0.055, 0.075, uQuality) *
@@ -679,13 +689,14 @@ void main() {
trailRefraction * trailStrength +
temporalFlow * temporalStrength +
wakeRefraction
) * rectMask;
edge = max(edge, edgeResponse * rectMask);
) * rectMask * surfaceDynamic;
edge = max(edge, edgeResponse * rectMask * surfaceDynamic);
caustic = max(caustic, localCaustic);
directionalReflection = max(directionalReflection, localDirectionalReflection);
topPrism = max(topPrism, localTopPrism);
backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption);
materialEnergy = max(materialEnergy, liquidEnergy * rectMask);
materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic);
dynamicMask = max(dynamicMask, rectMask * surfaceDynamic);
mask = max(mask, rectMask);
}
@@ -707,7 +718,7 @@ void main() {
(
0.82 +
materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
flowSurfaceDetail * 0.38
flowSurfaceDetail * dynamicMask * 0.38
);
vec3 diffused;
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 生命周期内的稳定身份。 */
function collectGlassOpticalSurfaceDescriptors(
viewportWidth: number,
@@ -879,6 +897,7 @@ function collectGlassOpticalSurfaceDescriptors(
candidates.push({
key: element,
mode: resolveGlassOpticalSurfaceMode(element),
rect: {
height: bounds.height,
radii: [...readBorderRadii(element)] as GlassCornerRadii,
@@ -899,12 +918,13 @@ function collectGlassOpticalSurfaceDescriptors(
const { rect } = candidate
const nested = selected.some(
parent =>
parent.mode === candidate.mode &&
rect.x >= parent.rect.x &&
rect.y >= parent.rect.y &&
rect.x + rect.width <= parent.rect.x + parent.rect.width &&
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
@@ -979,7 +999,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let activeTouchIdentifier: number | null = null
let pendingFlowInjection = 0
let interactionAnimating = false
let surfaceResizeTimer: number | null = null
let scrollAnimationFrame: number | null = null
let scrollDirty = false
let scrollSurfaceRefreshPending = false
@@ -1030,6 +1049,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
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() {
if (animationFrame === null) return
@@ -1237,6 +1266,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const uniformRects = resources.uniforms.uRects.value
const uniformRadii = resources.uniforms.uRadii.value
const uniformWeights = resources.uniforms.uSurfaceWeights.value
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
const transitionWeights = outgoingSurface
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
: { incoming: 1, outgoing: 0 }
@@ -1256,6 +1286,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
: slot
? 1
: 0
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
}
resources.uniforms.uRectCount.value = normalized.length
@@ -1296,9 +1327,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
nextObservedSurfaces.some((element, index) => element !== observedSurfaces[index])
if (observedSurfacesChanged) {
resizeObserver?.disconnect()
observedSurfaces = nextObservedSurfaces
for (const element of observedSurfaces) resizeObserver?.observe(element)
observeResizeTargets()
}
if (scheduleRender) scheduleFrame()
}
@@ -1349,14 +1379,19 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
surfaceStabilityFrame = requestAnimationFrame(sample)
}
function scheduleSurfaceResizeUpdate() {
if (surfaceResizeTimer !== null) window.clearTimeout(surfaceResizeTimer)
/** ResizeObserver 在浏览器绘制前提交新尺寸,避免 CSS 与 WebGL buffer 跨帧失配。 */
function handleSurfaceResize(entries: ResizeObserverEntry[]) {
if (!resources) return
surfaceResizeTimer = window.setTimeout(() => {
surfaceResizeTimer = null
if (presentationSpace === 'scroll') resizeRenderer()
else scheduleSurfaceUpdate()
}, 160)
const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
if (presentationRoot && entries.some(entry => entry.target === presentationRoot)) {
resizeRenderer()
return
}
const timestamp = performance.now()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
}
/** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */
@@ -1488,9 +1523,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
profile.bufferQuality,
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
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.uPresentationSize.value.set(presentation.width, presentation.height)
resources.uniforms.uScrollOffset.value.set(
@@ -1500,13 +1539,20 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (flowResources) {
const flowWidth = Math.max(96, Math.round(buffer.width * FLOW_BUFFER_SCALE))
const flowHeight = Math.max(96, Math.round(buffer.height * FLOW_BUFFER_SCALE))
flowResources.readTarget.setSize(flowWidth, flowHeight)
flowResources.writeTarget.setSize(flowWidth, flowHeight)
const flowBufferChanged =
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.uViewportAspect.value = viewportWidth / Math.max(viewportHeight, 1)
}
syncCoverScale(viewportWidth, viewportHeight)
scheduleSurfaceUpdate()
updateSurfaceUniforms(performance.now(), false)
// buffer 重分配或归一化呈现尺寸变化都必须在当前绘制周期提交稳定画面。
if (bufferChanged || presentationChanged) renderFrame(performance.now(), false)
else scheduleSurfaceUpdate()
}
function profileRequiresTextureReload(
@@ -1749,7 +1795,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
lastPointerX = clientX
lastPointerY = clientY
lastPointerAt = timestamp
if (!resources || !surface) return
if (!resources || !surface || surface.mode === 'static-material') return
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
const surfaceChanged = activateInteractionSurface(surface.key, timestamp, reducedMotion)
@@ -1833,7 +1879,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const point = getPresentationPoint(touch.clientX, touch.clientY)
const presentation = getPresentationSize()
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)
snapPointer(point.x / Math.max(presentation.width, 1), 1 - point.y / Math.max(presentation.height, 1))
scheduleFrame()
@@ -2018,14 +2064,20 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}
function setupObservers() {
resizeObserver = new ResizeObserver(scheduleSurfaceResizeUpdate)
resizeObserver = new ResizeObserver(handleSurfaceResize)
observeResizeTargets(false)
const observedMutationRoots = new Set<Node>()
function observeMutationRoot(root: Node | null, subtree: boolean) {
if (!root || observedMutationRoots.has(root)) return
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 => {
@@ -2104,10 +2156,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
cancelAnimationFrame(surfaceStabilityFrame)
surfaceStabilityFrame = null
}
if (surfaceResizeTimer !== null) {
window.clearTimeout(surfaceResizeTimer)
surfaceResizeTimer = null
}
removeEvents()
resizeObserver?.disconnect()
resizeObserver = null
@@ -2393,6 +2441,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
uRectCount: { value: 0 },
uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
uPreviousTexture: { value: null },
uTexture: { value: null },
uTextureMix: { value: 1 },
@@ -2578,7 +2627,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
() => toValue(options.routeKey),
async (routeKey, previousRouteKey) => {
const previousProfile = getRenderProfile(previousRouteKey ?? '')
await nextTick()
if (resources) {
const nextProfile = getRenderProfile(routeKey)
resizeRenderer()
@@ -2590,6 +2638,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}
scheduleSurfaceUpdate()
},
{ flush: 'post' },
)
onScopeDispose(() => {

View File

@@ -156,8 +156,10 @@ const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
applyPendingHorizontalTab()
}
/** 取消当前页面注册的动态标签页。 */
const unregisterDynamicHeaderTab = () => {
/** 仅注销仍由指定路由持有的动态标签,避免相邻页面生命周期互相覆盖。 */
const unregisterDynamicHeaderTab = (routePath?: string) => {
if (routePath && dynamicHeaderTab.value?.routePath !== routePath) return
dynamicHeaderTab.value = null
}
@@ -844,5 +846,4 @@ onMounted(async () => {
align-items: center;
margin-inline-start: auto;
}
</style>

View File

@@ -26,6 +26,10 @@ const downloaderItems = computed(() => {
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
registerHeaderTab({
items: downloaderItems,
modelValue: activeTab,
})
// 调用API查询下载器设置
async function loadDownloaderSetting() {
@@ -38,24 +42,12 @@ async function loadDownloaderSetting() {
}
}
// 注册动态标签页
const registerTabs = () => {
if (downloaderItems.value.length > 0) {
registerHeaderTab({
items: downloaderItems,
modelValue: activeTab,
})
}
}
onMounted(async () => {
await loadDownloaderSetting()
registerTabs()
})
useKeepAliveRefresh(async () => {
await loadDownloaderSetting()
registerTabs()
})
</script>

View File

@@ -3,5 +3,7 @@ import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
</script>
<template>
<FileBrowserView />
<div data-glass-optical-mode="static-material">
<FileBrowserView />
</div>
</template>

View File

@@ -3,7 +3,7 @@ import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'
</script>
<template>
<div>
<div data-glass-optical-mode="static-material">
<TransferHistoryView />
</div>
</template>

View File

@@ -42,7 +42,7 @@ watch(
</script>
<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="组件加载错误">
无法加载插件全页组件多入口时请暴露 AppPage AppPage{Pascal}见文档并确认插件已启用
</VAlert>

View File

@@ -1,5 +1,6 @@
import {
BACKGROUND_ROTATION_GRACE_MS,
createBackgroundCandidateOrderResolver,
findFirstAvailableBackground,
preloadBackgroundRotationImages,
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', () => {
it('does not let an unused optical texture block the visible wallpaper', async () => {
const preload = vi.fn(async (url: string) => url === 'display.jpg')

View File

@@ -3,11 +3,36 @@ import type { AppActivityState } from '@/utils/appActivityLifecycle'
/** 壁纸在窗口失焦后继续轮换的最长时间,交互 renderer 仍由应用生命周期独立暂停。 */
export const BACKGROUND_ROTATION_GRACE_MS = 60_000
type RandomSource = () => number
/** 壁纸轮换只在前台活动或失焦宽限期内运行,系统减少动态效果时始终停止。 */
export function shouldAllowBackgroundRotation(state: AppActivityState, graceActive: boolean, reducedMotion: boolean) {
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 {
/** 外层背景实际显示的壁纸地址。 */
displayUrl: string
@@ -18,7 +43,7 @@ interface BackgroundRotationImagePreloadOptions {
}
interface FirstAvailableBackgroundOptions {
/** 保持后端返回顺序的候选壁纸。 */
/** 当前页面生命周期已确定顺序的候选壁纸。 */
urls: string[]
/** 当前加载批次仍可提交时返回 true。 */
canContinue: () => boolean
@@ -26,7 +51,7 @@ interface FirstAvailableBackgroundOptions {
preload: (url: string) => Promise<boolean>
}
/** 按来源顺序寻找首张可用壁纸,单项失败或过期批次不会提交可见状态。 */
/** 按当前候选顺序寻找首张可用壁纸,单项失败或过期批次不会提交可见状态。 */
export async function findFirstAvailableBackground(options: FirstAvailableBackgroundOptions) {
for (let index = 0; index < options.urls.length; index += 1) {
if (!options.canContinue()) return null

View File

@@ -72,9 +72,14 @@ export interface GlassOpticalSpringState {
velocity: number
}
/** 光学表面在共享 renderer 中采用的动态响应合同。 */
export type GlassOpticalSurfaceMode = 'dynamic' | 'static-material'
export interface GlassOpticalSurfaceCandidate<TKey> {
/** renderer 生命周期内稳定的表面身份。 */
key: TKey
/** 表面使用完整动态光学,或只保留稳定材质能量。 */
mode?: GlassOpticalSurfaceMode
/** 表面的当前视口几何。 */
rect: GlassOpticalRect
}