mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 10:34:35 +08:00
fix(glass): stabilize optical surfaces and page transitions (#594)
This commit is contained in:
@@ -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 容器:
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
67
src/composables/__tests__/useDynamicHeaderTab.spec.ts
Normal file
67
src/composables/__tests__/useDynamicHeaderTab.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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缓存的页面)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -3,5 +3,7 @@ import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FileBrowserView />
|
||||
<div data-glass-optical-mode="static-material">
|
||||
<FileBrowserView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-glass-optical-mode="static-material">
|
||||
<TransferHistoryView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user