mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 09:05:28 +08:00
fix(glass): stabilize optical rendering and page presentation (#595)
This commit is contained in:
71
src/App.vue
71
src/App.vue
@@ -41,7 +41,13 @@ import {
|
||||
getLoginVisualProfile,
|
||||
prepareLoginBackgroundLayer,
|
||||
settleLoginBackgroundLayers,
|
||||
type LoginBackgroundLayer,
|
||||
} from '@/utils/loginPresentation'
|
||||
import {
|
||||
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||
loadGlassWallpaperToneProfile,
|
||||
type GlassWallpaperToneProfile,
|
||||
} from '@/utils/glassWallpaperTone'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
@@ -83,6 +89,7 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
// 背景图片
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const backgroundLayers = ref(createLoginBackgroundLayers())
|
||||
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
|
||||
const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
const isBackgroundCrossfading = ref(false)
|
||||
@@ -212,6 +219,31 @@ function handleTransparencySettingsChanged(event: Event) {
|
||||
transparencyGlassQuality.value = glassQuality
|
||||
}
|
||||
|
||||
/** 在壁纸可见前准备稳健曝光;不可读跨域图片回落中性 profile,不阻断 CSS 背景。 */
|
||||
async function ensureBackgroundToneProfile(imageUrl: string) {
|
||||
if (!imageUrl || !isGlassTheme.value) return DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
|
||||
|
||||
const profile = await loadGlassWallpaperToneProfile(getOpticalBackgroundImage(imageUrl))
|
||||
backgroundToneProfiles.value = {
|
||||
...backgroundToneProfiles.value,
|
||||
[imageUrl]: profile,
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 让稳定双槽位分别携带当前壁纸的曝光,交叉淡化期间不共享新图参数。 */
|
||||
function getBackgroundLayerStyle(layer: LoginBackgroundLayer) {
|
||||
const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
|
||||
const appearance = effectiveGlassSettings.value.glassAppearance
|
||||
const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86
|
||||
|
||||
return {
|
||||
'backgroundImage': layer.url ? `url(${layer.url})` : undefined,
|
||||
'--glass-wallpaper-brightness': String(materialExposure * profile.exposure),
|
||||
}
|
||||
}
|
||||
|
||||
applyTransparentBackgroundSettings()
|
||||
|
||||
void router.isReady().then(() => {
|
||||
@@ -501,13 +533,29 @@ function preloadNextBackgroundImage() {
|
||||
|
||||
/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */
|
||||
async function preloadBackgroundCandidate(imageUrl: string) {
|
||||
if (!shouldRenderGlassOpticalLayer.value) return preloadImage(imageUrl)
|
||||
const toneProfile = isGlassTheme.value
|
||||
? ensureBackgroundToneProfile(imageUrl)
|
||||
: Promise.resolve(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
||||
if (!shouldRenderGlassOpticalLayer.value) {
|
||||
const [available] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
const opticalUrl = getOpticalBackgroundImage(imageUrl)
|
||||
const opticalReady = await preloadCorsImage(opticalUrl)
|
||||
if (!opticalReady) return preloadImage(imageUrl)
|
||||
if (!opticalReady) {
|
||||
const [displayReady] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
||||
|
||||
return opticalUrl === imageUrl ? true : preloadImage(imageUrl)
|
||||
return displayReady
|
||||
}
|
||||
|
||||
const [displayReady] = await Promise.all([
|
||||
opticalUrl === imageUrl ? Promise.resolve(true) : preloadImage(imageUrl),
|
||||
toneProfile,
|
||||
])
|
||||
|
||||
return displayReady
|
||||
}
|
||||
|
||||
// 背景图片轮换函数
|
||||
@@ -529,6 +577,7 @@ async function rotateBackgroundImage() {
|
||||
preload: preloadImage,
|
||||
})
|
||||
if (!imagesReady) continue
|
||||
await ensureBackgroundToneProfile(nextImage)
|
||||
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
||||
|
||||
activateBackgroundImage(nextIndex)
|
||||
@@ -811,6 +860,14 @@ onMounted(async () => {
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(isGlassTheme, enabled => {
|
||||
if (!enabled) return
|
||||
|
||||
void Promise.all(
|
||||
renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => ensureBackgroundToneProfile(layer.url)),
|
||||
)
|
||||
})
|
||||
|
||||
// 使用优化后的加载界面移除逻辑
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
@@ -883,7 +940,7 @@ onUnmounted(() => {
|
||||
:key="layer.key"
|
||||
class="background-image"
|
||||
:class="layer.role"
|
||||
:style="{ 'backgroundImage': layer.url ? `url(${layer.url})` : undefined }"
|
||||
:style="getBackgroundLayerStyle(layer)"
|
||||
/>
|
||||
<!-- 全局磨砂层 -->
|
||||
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
|
||||
@@ -985,7 +1042,7 @@ onUnmounted(() => {
|
||||
|
||||
.background-container.is-glass-theme .background-image.active,
|
||||
.background-container.is-glass-theme .background-image.previous {
|
||||
filter: brightness(0.86) saturate(0.95) contrast(1.02);
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.86)) saturate(0.95) contrast(1.02);
|
||||
}
|
||||
|
||||
.background-container.is-glass-theme .background-image.active {
|
||||
@@ -1001,7 +1058,7 @@ onUnmounted(() => {
|
||||
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active,
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous {
|
||||
filter: brightness(0.85) saturate(0.97) contrast(1.02);
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.85)) saturate(0.97) contrast(1.02);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active {
|
||||
@@ -1017,7 +1074,7 @@ html[data-glass-appearance='tinted'] .background-container.is-glass-theme .backg
|
||||
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active,
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous {
|
||||
filter: brightness(0.82) saturate(0.9);
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.82)) saturate(0.9);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
||||
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||
import {
|
||||
setGlassRendererState,
|
||||
useGlassOpticalInteractionSource,
|
||||
@@ -47,6 +48,7 @@ const emit = defineEmits<{
|
||||
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const interactionSource = useGlassOpticalInteractionSource()
|
||||
const pagePresentationMotion = usePagePresentationMotion()
|
||||
const fixedRenderer = useGlassOpticalRenderer({
|
||||
active: true,
|
||||
appearance: () => props.appearance,
|
||||
@@ -76,6 +78,7 @@ const scrollRenderer = useGlassOpticalRenderer({
|
||||
deformationStrength: () => props.deformationStrength,
|
||||
flowStrength: () => props.flowStrength,
|
||||
interactionSource,
|
||||
pageMotion: pagePresentationMotion.reader,
|
||||
quality: () => props.quality,
|
||||
reflectionStrength: () => props.reflectionStrength,
|
||||
transparencyStrength: () => props.transparencyStrength,
|
||||
|
||||
@@ -53,6 +53,13 @@ describe('GlassOpticalLayer', () => {
|
||||
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
||||
expect(rendererCalls[0].pageMotion).toBeUndefined()
|
||||
expect(rendererCalls[1].pageMotion).toEqual(
|
||||
expect.objectContaining({
|
||||
opacity: expect.any(Object),
|
||||
revision: expect.any(Object),
|
||||
}),
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||
|
||||
@@ -32,6 +32,9 @@ vi.mock('three', async importOriginal => {
|
||||
clear() {}
|
||||
dispose() {}
|
||||
forceContextLoss() {}
|
||||
getRenderTarget() {
|
||||
return null
|
||||
}
|
||||
render() {}
|
||||
setClearColor() {}
|
||||
setPixelRatio() {}
|
||||
@@ -347,6 +350,82 @@ describe('glass optical surface discovery', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('re-samples scroll surfaces and material weight on a shared page motion revision', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
const three = await import('three')
|
||||
const root = document.createElement('div')
|
||||
const canvas = document.createElement('canvas')
|
||||
let presentationHeight = 800
|
||||
Object.defineProperty(root, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => presentationHeight,
|
||||
})
|
||||
root.append(canvas)
|
||||
document.body.append(root)
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const setSize = vi.spyOn(three.WebGLRenderer.prototype, 'setSize')
|
||||
const bounds = { height: 240, width: 360, x: 80, y: 100 }
|
||||
appendOpticalSurface('app-hover-lift-card', bounds)
|
||||
const active = ref(true)
|
||||
const opacity = ref(1)
|
||||
const revision = ref(0)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
pageMotion: { active, opacity, revision },
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const initialScene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uRects: { value: Array<{ y: number }> }
|
||||
uSurfaceWeights: { value: number[] }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
const initialY = initialScene.children[0].material.uniforms.uRects.value[0].y
|
||||
render.mockClear()
|
||||
setSize.mockClear()
|
||||
|
||||
bounds.y = 140
|
||||
presentationHeight = 1200
|
||||
opacity.value = 0.42
|
||||
revision.value += 1
|
||||
|
||||
expect(setSize).toHaveBeenCalledOnce()
|
||||
expect(setSize).toHaveBeenCalledWith(1200, 1200, false)
|
||||
expect(render).toHaveBeenCalled()
|
||||
const motionScene = render.mock.calls.at(-1)?.[0] as unknown as typeof initialScene
|
||||
const uniforms = motionScene.children[0].material.uniforms
|
||||
expect(uniforms.uRects.value[0].y).not.toBe(initialY)
|
||||
expect(uniforms.uSurfaceWeights.value[0]).toBeCloseTo(0.42)
|
||||
|
||||
const observer = ResizeObserverMock.instances.find(instance => instance.targets.has(root))
|
||||
expect(observer).toBeDefined()
|
||||
setSize.mockClear()
|
||||
render.mockClear()
|
||||
presentationHeight = 1400
|
||||
observer?.trigger()
|
||||
|
||||
expect(setSize).toHaveBeenCalledOnce()
|
||||
expect(setSize).toHaveBeenCalledWith(1200, 1400, false)
|
||||
expect(render).toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('recovers after consecutive WebGL context loss cycles', async () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const scope = effectScope()
|
||||
@@ -376,6 +455,36 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps the renderer ready when the optional frosted prefilter fails', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const compile = vi
|
||||
.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
.mockRejectedValueOnce(new Error('prefilter unavailable'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('frosted'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('high'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(compile).toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'玻璃磨砂壁纸预滤失败,继续使用实时扩散采样:',
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('releases renderer resources before restoring a single active instance', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -427,7 +536,7 @@ describe('glass optical surface discovery', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(rendererDispose).toHaveBeenCalledTimes(1)
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeFirstRelease + 2)
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeFirstRelease + 3)
|
||||
expect(contextLoss).toHaveBeenCalledTimes(1)
|
||||
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
|
||||
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
|
||||
@@ -460,7 +569,7 @@ describe('glass optical surface discovery', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(rendererDispose).toHaveBeenCalledTimes(2)
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeSecondRelease + 2)
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeSecondRelease + 3)
|
||||
expect(contextLoss).toHaveBeenCalledTimes(2)
|
||||
expect(resizeDisconnect).toHaveBeenCalledTimes(2)
|
||||
expect(mutationDisconnect).toHaveBeenCalledTimes(2)
|
||||
@@ -493,19 +602,17 @@ describe('glass optical surface discovery', () => {
|
||||
|
||||
quality.value = 'balanced'
|
||||
await nextTick()
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
await vi.waitFor(() => expect(renderTargetDispose).toHaveBeenCalledTimes(4))
|
||||
|
||||
expect(rendererDispose).not.toHaveBeenCalled()
|
||||
expect(contextLoss).not.toHaveBeenCalled()
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(2)
|
||||
|
||||
quality.value = 'high'
|
||||
await nextTick()
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
await vi.waitFor(() => expect(renderTargetDispose).toHaveBeenCalledTimes(6))
|
||||
|
||||
expect(rendererDispose).not.toHaveBeenCalled()
|
||||
expect(contextLoss).not.toHaveBeenCalled()
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(4)
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
@@ -676,6 +783,9 @@ describe('glass optical surface discovery', () => {
|
||||
material: {
|
||||
fragmentShader: string
|
||||
uniforms: {
|
||||
uFrostedTexture: { value: unknown }
|
||||
uHasFrostedTexture: { value: number }
|
||||
uPreviousFrostedTexture: { value: unknown }
|
||||
uPreviousTexture: { value: unknown }
|
||||
uTexture: { value: unknown }
|
||||
uTextureMix: { value: number }
|
||||
@@ -685,11 +795,17 @@ describe('glass optical surface discovery', () => {
|
||||
}
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
expect(uniforms.uPreviousTexture.value).not.toBe(uniforms.uTexture.value)
|
||||
expect(uniforms.uPreviousFrostedTexture.value).not.toBe(uniforms.uFrostedTexture.value)
|
||||
expect(uniforms.uHasFrostedTexture.value).toBe(1)
|
||||
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)',
|
||||
'toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'toneMapWallpaper(current, viewportUv, uWallpaperExposure)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('return mix(previousTone, currentTone, uTextureMix)')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain(
|
||||
'toneMapWallpaper(mix(previous, current, uTextureMix), viewportUv)',
|
||||
)
|
||||
@@ -734,7 +850,72 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('resizes a scroll buffer when an asynchronous page grows without optical surfaces', async () => {
|
||||
it('keeps the route first frame while transient scroll presentation heights settle', async () => {
|
||||
vi.useFakeTimers()
|
||||
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 routeKey = ref('/resource')
|
||||
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,
|
||||
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 = 1744
|
||||
routeKey.value = '/dashboard'
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
expect(setSize).toHaveBeenCalledOnce()
|
||||
expect(setSize).toHaveBeenCalledWith(390, 1744, false)
|
||||
|
||||
setSize.mockClear()
|
||||
presentationHeight = 2320
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(setSize).not.toHaveBeenCalled()
|
||||
|
||||
observer?.trigger()
|
||||
presentationHeight = 2170
|
||||
observer?.trigger()
|
||||
presentationHeight = 1744
|
||||
observer?.trigger()
|
||||
|
||||
expect(setSize).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(159)
|
||||
expect(setSize).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
expect(setSize).not.toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('resizes a scroll buffer after an asynchronous page height becomes stable', async () => {
|
||||
vi.useFakeTimers()
|
||||
const three = await import('three')
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
|
||||
@@ -771,12 +952,51 @@ describe('glass optical surface discovery', () => {
|
||||
|
||||
presentationHeight = 5000
|
||||
observer?.trigger()
|
||||
vi.advanceTimersByTime(159)
|
||||
expect(setSize).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
expect(setSize).toHaveBeenCalledOnce()
|
||||
expect(setSize).toHaveBeenCalledWith(390, 3072, false)
|
||||
expect(render).toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('updates surface geometry when the presentation root shares a resize delivery', async () => {
|
||||
const three = await import('three')
|
||||
const root = document.createElement('div')
|
||||
const canvas = document.createElement('canvas')
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 320, x: 40, y: 80 })
|
||||
root.append(canvas)
|
||||
document.body.append(root)
|
||||
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('/dashboard'),
|
||||
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) && instance.targets.has(surface),
|
||||
)
|
||||
expect(observer).toBeDefined()
|
||||
render.mockClear()
|
||||
|
||||
observer?.trigger()
|
||||
|
||||
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 })
|
||||
@@ -1557,13 +1777,43 @@ describe('glass optical surface discovery', () => {
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uReflectionStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransparency')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransmissionStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uPreviousWallpaperExposure')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uWallpaperExposure')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('compressWallpaperLuminance')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'toneMapWallpaper(current, viewportUv, uWallpaperExposure)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float transmissionResponse')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float referenceLiftProgress')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float highTransmissionProgress')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float frostedDensity')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'frosted * (1.0 - smoothstep(0.25, 0.9, uTransparency))',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'1.0 + frostedDensity * mix(1.15, 1.55, uQuality)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'smoothstep(0.02, 0.55, liquidPresence)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'mix(uTransparency * 0.26, 0.92, opticalCoverage)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'mix(frostedBaseAlpha, 0.94, opticalCoverage)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'mix(uTransparency * 0.44, 0.92, opticalCoverage)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('vec3 transmissionReference')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float shadowLiftGate')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float shadowColorRetention')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float referenceLift')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float highlightProtection')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('protectedHighlightReference')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('mix(0.58, 0.92, uQuality)')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float compressedLuminance')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('mix(0.58, 0.84, uQuality)')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uMotion *')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uTranslationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('vec2 lightDirection = normalize(vec2(-0.68, 0.74))')
|
||||
@@ -1572,7 +1822,9 @@ describe('glass optical surface discovery', () => {
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'causticHighlightMix * uReflectionStrength * highlightBudget',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('mix(0.78, 0.94, uTransparency)')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain(
|
||||
'materialAlpha = uTransparency * mix(',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('mix(0.035, 0.4')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('sin(')
|
||||
|
||||
|
||||
163
src/composables/__tests__/usePagePresentationMotion.spec.ts
Normal file
163
src/composables/__tests__/usePagePresentationMotion.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
getPagePresentationMotionProgress,
|
||||
PAGE_PRESENTATION_MOTION_DURATION_MS,
|
||||
PAGE_PRESENTATION_MOTION_START_OPACITY,
|
||||
PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y,
|
||||
usePagePresentationMotion,
|
||||
} from '@/composables/usePagePresentationMotion'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const motion = usePagePresentationMotion()
|
||||
let callbacks: Map<number, FrameRequestCallback>
|
||||
let frameId: number
|
||||
|
||||
beforeEach(() => {
|
||||
motion.cancel()
|
||||
callbacks = new Map()
|
||||
frameId = 0
|
||||
document.documentElement.dataset.theme = 'glass'
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
motion.cancel()
|
||||
delete document.documentElement.dataset.theme
|
||||
delete document.documentElement.dataset.pagePresentationMotion
|
||||
document.documentElement.style.removeProperty('--mp-page-motion-opacity')
|
||||
document.documentElement.style.removeProperty('--mp-page-motion-translate-y')
|
||||
})
|
||||
|
||||
describe('page presentation motion', () => {
|
||||
it('holds a glass route until its shared layout geometry remains stable', () => {
|
||||
const routeRoot = document.createElement('div')
|
||||
let routeHeight = 2096
|
||||
Object.defineProperties(routeRoot, {
|
||||
offsetHeight: { configurable: true, get: () => routeHeight },
|
||||
offsetWidth: { configurable: true, get: () => 1200 },
|
||||
scrollHeight: { configurable: true, get: () => routeHeight },
|
||||
scrollWidth: { configurable: true, get: () => 1200 },
|
||||
})
|
||||
document.body.append(routeRoot)
|
||||
|
||||
expect(motion.start('/dashboard', routeRoot)).toBe(true)
|
||||
expect(motion.active.value).toBe(true)
|
||||
expect(motion.opacity.value).toBe(0)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBe('active')
|
||||
|
||||
;[1016, 1080].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp))
|
||||
expect(motion.opacity.value).toBe(0)
|
||||
|
||||
routeHeight = 1520
|
||||
;[1110, 1200].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp))
|
||||
expect(motion.opacity.value).toBe(0)
|
||||
|
||||
;[1231].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp))
|
||||
expect(motion.opacity.value).toBe(PAGE_PRESENTATION_MOTION_START_OPACITY)
|
||||
expect(motion.translateY.value).toBe(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y)
|
||||
|
||||
routeRoot.remove()
|
||||
})
|
||||
|
||||
it('uses one eased timeline for the initial, intermediate, and settled states', () => {
|
||||
const initialRevision = motion.revision.value
|
||||
|
||||
expect(motion.start('/dashboard')).toBe(true)
|
||||
expect(motion.active.value).toBe(true)
|
||||
expect(motion.opacity.value).toBe(PAGE_PRESENTATION_MOTION_START_OPACITY)
|
||||
expect(motion.translateY.value).toBe(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y)
|
||||
expect(motion.revision.value).toBe(initialRevision + 1)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBe('active')
|
||||
|
||||
const [firstFrame] = callbacks.values()
|
||||
firstFrame(1000 + PAGE_PRESENTATION_MOTION_DURATION_MS / 2)
|
||||
expect(motion.progress.value).toBeGreaterThan(0)
|
||||
expect(motion.progress.value).toBeLessThan(1)
|
||||
expect(motion.opacity.value).toBeGreaterThan(PAGE_PRESENTATION_MOTION_START_OPACITY)
|
||||
expect(motion.opacity.value).toBeLessThan(1)
|
||||
expect(motion.translateY.value).toBeGreaterThan(0)
|
||||
expect(motion.translateY.value).toBeLessThan(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y)
|
||||
|
||||
const finalFrame = [...callbacks.values()].at(-1)!
|
||||
finalFrame(1000 + PAGE_PRESENTATION_MOTION_DURATION_MS)
|
||||
expect(motion.active.value).toBe(false)
|
||||
expect(motion.progress.value).toBe(1)
|
||||
expect(motion.opacity.value).toBe(1)
|
||||
expect(motion.translateY.value).toBe(0)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBeUndefined()
|
||||
})
|
||||
|
||||
it('invalidates stale route frames without cancelling the current epoch', () => {
|
||||
motion.start('/first')
|
||||
const [staleFrame] = callbacks.values()
|
||||
|
||||
motion.start('/second')
|
||||
const currentRevision = motion.revision.value
|
||||
const currentFrame = [...callbacks.values()].at(-1)!
|
||||
staleFrame(1016)
|
||||
|
||||
expect(motion.routeKey.value).toBe('/second')
|
||||
expect(motion.revision.value).toBe(currentRevision)
|
||||
expect([...callbacks.values()]).toContain(currentFrame)
|
||||
|
||||
currentFrame(1000 + PAGE_PRESENTATION_MOTION_DURATION_MS)
|
||||
expect(motion.opacity.value).toBe(1)
|
||||
})
|
||||
|
||||
it('publishes a final renderer revision when an active motion is cancelled', () => {
|
||||
motion.start('/dashboard')
|
||||
const [frame] = callbacks.values()
|
||||
frame(1040)
|
||||
const activeRevision = motion.revision.value
|
||||
|
||||
motion.cancel()
|
||||
|
||||
expect(motion.active.value).toBe(false)
|
||||
expect(motion.opacity.value).toBe(1)
|
||||
expect(motion.translateY.value).toBe(0)
|
||||
expect(motion.revision.value).toBe(activeRevision + 1)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBeUndefined()
|
||||
})
|
||||
|
||||
it('settles immediately when reduced motion is requested', () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
...window.matchMedia(''),
|
||||
matches: true,
|
||||
})
|
||||
const initialRevision = motion.revision.value
|
||||
|
||||
expect(motion.start('/dashboard')).toBe(true)
|
||||
expect(motion.active.value).toBe(false)
|
||||
expect(motion.opacity.value).toBe(1)
|
||||
expect(motion.revision.value).toBe(initialRevision + 1)
|
||||
expect(callbacks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves non-glass themes on the existing CSS animation path', () => {
|
||||
document.documentElement.dataset.theme = 'dark'
|
||||
|
||||
expect(motion.start('/dashboard')).toBe(false)
|
||||
expect(motion.active.value).toBe(false)
|
||||
expect(callbacks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the shared cubic-bezier progress bounded and monotonic', () => {
|
||||
const quarter = getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS * 0.25)
|
||||
const half = getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS * 0.5)
|
||||
const threeQuarters = getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS * 0.75)
|
||||
|
||||
expect(getPagePresentationMotionProgress(0)).toBe(0)
|
||||
expect(quarter).toBeGreaterThan(0)
|
||||
expect(half).toBeGreaterThan(quarter)
|
||||
expect(threeQuarters).toBeGreaterThan(half)
|
||||
expect(getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,13 @@ import {
|
||||
type GlassOpticalSurfaceSlot,
|
||||
} from '@/utils/glassOptics'
|
||||
import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer'
|
||||
import type { PagePresentationMotionReader } from '@/composables/usePagePresentationMotion'
|
||||
import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle'
|
||||
import {
|
||||
analyzeGlassWallpaperTone,
|
||||
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||
type GlassWallpaperToneProfile,
|
||||
} from '@/utils/glassWallpaperTone'
|
||||
|
||||
type ThreeModule = typeof import('three')
|
||||
export type GlassRendererState = 'fallback' | 'loading' | 'ready'
|
||||
@@ -154,6 +160,7 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
|
||||
uFlowStrength: IUniform<number>
|
||||
uHasWallpaperTexture: IUniform<number>
|
||||
uHasFlowTexture: IUniform<number>
|
||||
uHasFrostedTexture: IUniform<number>
|
||||
uMotion: IUniform<number>
|
||||
uMotionExpansion: IUniform<number>
|
||||
uMaxRefractionPixels: IUniform<number>
|
||||
@@ -161,6 +168,7 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
|
||||
uPointerVelocity: IUniform<Vector2>
|
||||
uPresentationSize: IUniform<Vector2>
|
||||
uPreviousCoverScale: IUniform<Vector2>
|
||||
uPreviousWallpaperExposure: IUniform<number>
|
||||
uQuality: IUniform<number>
|
||||
uReflectionStrength: IUniform<number>
|
||||
uRadii: IUniform<Vector4[]>
|
||||
@@ -169,8 +177,11 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
|
||||
uSurfaceWeights: IUniform<number[]>
|
||||
uSurfaceDynamics: IUniform<number[]>
|
||||
uPreviousTexture: IUniform<Texture | null>
|
||||
uPreviousFrostedTexture: IUniform<Texture | null>
|
||||
uTexture: IUniform<Texture | null>
|
||||
uFrostedTexture: IUniform<Texture | null>
|
||||
uTextureMix: IUniform<number>
|
||||
uWallpaperExposure: IUniform<number>
|
||||
uTintColor: IUniform<Color>
|
||||
uTransparency: IUniform<number>
|
||||
uTransmissionStrength: IUniform<number>
|
||||
@@ -212,6 +223,17 @@ interface GlassFlowResources {
|
||||
writeTarget: WebGLRenderTarget
|
||||
}
|
||||
|
||||
interface GlassFrostPrefilterResources {
|
||||
material: ShaderMaterial
|
||||
mesh: Mesh
|
||||
scene: Scene
|
||||
uniforms: {
|
||||
uDirection: IUniform<Vector2>
|
||||
uTexture: IUniform<Texture | null>
|
||||
uTextureSize: IUniform<Vector2>
|
||||
}
|
||||
}
|
||||
|
||||
interface UseGlassOpticalRendererOptions {
|
||||
active: MaybeRefOrGetter<boolean>
|
||||
appearance: MaybeRefOrGetter<ThemeCustomizerGlassAppearance>
|
||||
@@ -221,6 +243,8 @@ interface UseGlassOpticalRendererOptions {
|
||||
interactionSource?: GlassOpticalInteractionSource
|
||||
/** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */
|
||||
motionStrength?: MaybeRefOrGetter<number>
|
||||
/** scroll-space 页面表面与 DOM 共用的短时呈现状态;fixed-space 不参与路由入场。 */
|
||||
pageMotion?: PagePresentationMotionReader
|
||||
quality: MaybeRefOrGetter<GlassOpticalQuality>
|
||||
reflectionStrength?: MaybeRefOrGetter<number>
|
||||
previousWallpaperUrl?: MaybeRefOrGetter<string>
|
||||
@@ -244,12 +268,16 @@ type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement> &
|
||||
}
|
||||
|
||||
interface PreparedWallpaperTexture {
|
||||
/** 单次预滤后的低分辨率壁纸;中间 RenderTarget 不跨准备阶段保留。 */
|
||||
frostedTarget: WebGLRenderTarget | null
|
||||
/** 是否包含可采样的真实壁纸,而非程序化回退纹理。 */
|
||||
hasWallpaperTexture: boolean
|
||||
/** 纹理像素高度。 */
|
||||
height: number
|
||||
/** 已完成当前 WebGL context 上传的纹理。 */
|
||||
texture: Texture
|
||||
/** 纹理自身的稳健整体曝光;双纹理过渡期间不可与另一张壁纸共用。 */
|
||||
toneProfile: GlassWallpaperToneProfile
|
||||
/** 纹理像素宽度。 */
|
||||
width: number
|
||||
}
|
||||
@@ -344,11 +372,33 @@ void main() {
|
||||
}
|
||||
`
|
||||
|
||||
const FROST_PREFILTER_FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uTexture;
|
||||
uniform vec2 uDirection;
|
||||
uniform vec2 uTextureSize;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 texel = uDirection / max(uTextureSize, vec2(1.0));
|
||||
vec3 color =
|
||||
texture2D(uTexture, vUv).rgb * 0.227027 +
|
||||
texture2D(uTexture, vUv + texel * 1.384615).rgb * 0.316216 +
|
||||
texture2D(uTexture, vUv - texel * 1.384615).rgb * 0.316216 +
|
||||
texture2D(uTexture, vUv + texel * 3.230769).rgb * 0.070270 +
|
||||
texture2D(uTexture, vUv - texel * 3.230769).rgb * 0.070270;
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uPreviousTexture;
|
||||
uniform sampler2D uTexture;
|
||||
uniform sampler2D uPreviousFrostedTexture;
|
||||
uniform sampler2D uFrostedTexture;
|
||||
uniform sampler2D uFlowTexture;
|
||||
uniform vec2 uCoverScale;
|
||||
uniform vec2 uPreviousCoverScale;
|
||||
@@ -359,6 +409,7 @@ uniform float uDeformationStrength;
|
||||
uniform float uFlowStrength;
|
||||
uniform float uHasWallpaperTexture;
|
||||
uniform float uHasFlowTexture;
|
||||
uniform float uHasFrostedTexture;
|
||||
uniform float uMotion;
|
||||
uniform float uMotionExpansion;
|
||||
uniform float uMaxRefractionPixels;
|
||||
@@ -379,6 +430,8 @@ uniform float uTransparency;
|
||||
uniform float uTransmissionStrength;
|
||||
uniform float uTranslationStrength;
|
||||
uniform float uTextureMix;
|
||||
uniform float uPreviousWallpaperExposure;
|
||||
uniform float uWallpaperExposure;
|
||||
uniform vec4 uTrail[4];
|
||||
uniform int uTrailCount;
|
||||
varying vec2 vUv;
|
||||
@@ -409,16 +462,30 @@ vec2 coverUv(vec2 uv) {
|
||||
return vec2(0.5) + (viewportUv - vec2(0.5)) * uCoverScale;
|
||||
}
|
||||
|
||||
vec3 toneMapWallpaper(vec3 color, vec2 uv) {
|
||||
vec3 compressWallpaperLuminance(vec3 color, float wallpaperExposure) {
|
||||
vec3 exposed = max(color * wallpaperExposure, vec3(0.0));
|
||||
float sourceLuminance = dot(exposed, vec3(0.2126, 0.7152, 0.0722));
|
||||
if (sourceLuminance <= 0.0001) return exposed;
|
||||
|
||||
float shadowLift = (1.0 - smoothstep(0.06, 0.5, sourceLuminance)) * 0.22;
|
||||
float highlightCompression = smoothstep(0.72, 0.98, sourceLuminance) * 0.08;
|
||||
float compressedLuminance =
|
||||
sourceLuminance * (1.0 + shadowLift) * (1.0 - highlightCompression);
|
||||
|
||||
return clamp(exposed * (compressedLuminance / sourceLuminance), 0.0, 1.0);
|
||||
}
|
||||
|
||||
vec3 toneMapWallpaper(vec3 color, vec2 uv, float wallpaperExposure) {
|
||||
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
|
||||
float frosted = step(1.5, uAppearance);
|
||||
float exposure = mix(0.86, 0.85, tinted);
|
||||
exposure = mix(exposure, 0.82, frosted);
|
||||
float saturation = mix(0.95, 0.97, tinted);
|
||||
float saturation = mix(0.82, 0.95, tinted);
|
||||
saturation = mix(saturation, 0.9, frosted);
|
||||
float contrast = mix(1.02, 1.0, frosted);
|
||||
float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||
vec3 mapped = mix(vec3(luminance), color, saturation);
|
||||
vec3 normalized = compressWallpaperLuminance(color, wallpaperExposure);
|
||||
float luminance = dot(normalized, vec3(0.2126, 0.7152, 0.0722));
|
||||
vec3 mapped = mix(vec3(luminance), normalized, saturation);
|
||||
mapped = clamp((mapped - vec3(0.5)) * contrast + vec3(0.5), 0.0, 1.0) * exposure;
|
||||
|
||||
float top = 1.0 - uv.y;
|
||||
@@ -429,54 +496,63 @@ vec3 toneMapWallpaper(vec3 color, vec2 uv) {
|
||||
float radialAbsorption = smoothstep(0.24, 0.92, length(radialDelta)) * mix(0.12, 0.14, tinted);
|
||||
radialAbsorption *= 1.0 - frosted;
|
||||
vec3 absorbed = mapped * (1.0 - linearAbsorption) * (1.0 - radialAbsorption);
|
||||
float transmissionExpansion = max(0.0, (uTransmissionStrength - 0.5) * 2.0);
|
||||
float transmissionResponse = pow(transmissionExpansion, mix(0.8, 0.65, uQuality));
|
||||
float transmissionMaterialScale = mix(1.0, 0.78, tinted);
|
||||
float transmissionResponse =
|
||||
pow(clamp(uTransmissionStrength, 0.0, 1.0), mix(0.9, 0.78, uQuality));
|
||||
float referenceLiftProgress = smoothstep(0.7, 1.0, uTransmissionStrength);
|
||||
float highTransmissionProgress =
|
||||
clamp((uTransmissionStrength - 1.0) / 0.3, 0.0, 1.0);
|
||||
float transmissionMaterialScale = mix(1.0, 0.9, tinted);
|
||||
transmissionMaterialScale = mix(transmissionMaterialScale, 0.42, frosted);
|
||||
float shadowGamma =
|
||||
1.0 -
|
||||
transmissionResponse *
|
||||
transmissionMaterialScale *
|
||||
mix(0.28, 0.5, uQuality);
|
||||
float shadowLiftGate = smoothstep(0.035, 0.2, luminance);
|
||||
float highlightProtection = smoothstep(0.72, 0.96, luminance);
|
||||
float protectedGamma = mix(max(shadowGamma, 0.72), shadowGamma, shadowLiftGate);
|
||||
protectedGamma = mix(protectedGamma, 1.0, highlightProtection);
|
||||
vec3 expandedSource = pow(max(color, vec3(0.0)), vec3(protectedGamma));
|
||||
float sourceLuminance = dot(expandedSource, vec3(0.2126, 0.7152, 0.0722));
|
||||
float shadowColorRetention = mix(0.42, 1.0, smoothstep(0.025, 0.18, luminance));
|
||||
expandedSource = mix(vec3(sourceLuminance), expandedSource, shadowColorRetention);
|
||||
sourceLuminance = dot(expandedSource, vec3(0.2126, 0.7152, 0.0722));
|
||||
vec3 transmissionReference = mix(vec3(sourceLuminance), expandedSource, mix(1.16, 1.08, frosted));
|
||||
transmissionReference =
|
||||
clamp((transmissionReference - vec3(0.5)) * 1.06 + vec3(0.5), 0.0, 1.0) *
|
||||
1.04;
|
||||
vec3 protectedHighlightReference = min(color * 1.02, vec3(1.0));
|
||||
float highlightProtection = smoothstep(0.68, 0.92, luminance);
|
||||
vec3 transmissionReference = normalized;
|
||||
float referenceLift =
|
||||
transmissionMaterialScale *
|
||||
(
|
||||
referenceLiftProgress * mix(0.18, 0.25, uQuality) +
|
||||
highTransmissionProgress * mix(0.1, 0.16, uQuality)
|
||||
);
|
||||
transmissionReference *= 1.0 + referenceLift * (1.0 - highlightProtection * 0.72);
|
||||
transmissionReference = min(transmissionReference, vec3(0.96));
|
||||
vec3 protectedHighlightReference = min(normalized, vec3(0.96));
|
||||
transmissionReference = mix(
|
||||
transmissionReference,
|
||||
protectedHighlightReference,
|
||||
highlightProtection * 0.72
|
||||
highlightProtection * 0.78
|
||||
);
|
||||
float transmissionMix = min(
|
||||
transmissionResponse *
|
||||
transmissionMaterialScale *
|
||||
mix(0.58, 0.92, uQuality),
|
||||
0.94
|
||||
mix(0.58, 0.84, uQuality),
|
||||
0.84
|
||||
);
|
||||
|
||||
return mix(absorbed, min(transmissionReference, vec3(1.0)), transmissionMix);
|
||||
return mix(absorbed, transmissionReference, transmissionMix);
|
||||
}
|
||||
|
||||
vec3 sampleWallpaper(vec2 uv) {
|
||||
vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001));
|
||||
vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale;
|
||||
vec3 previous = texture2D(uPreviousTexture, previousUv).rgb;
|
||||
vec3 current = texture2D(uTexture, uv).rgb;
|
||||
vec3 previous;
|
||||
vec3 current;
|
||||
if (uAppearance > 1.5 && uHasFrostedTexture > 0.5) {
|
||||
previous = texture2D(uPreviousFrostedTexture, previousUv).rgb;
|
||||
current = texture2D(uFrostedTexture, uv).rgb;
|
||||
} else {
|
||||
previous = texture2D(uPreviousTexture, previousUv).rgb;
|
||||
current = texture2D(uTexture, uv).rgb;
|
||||
}
|
||||
|
||||
if (uTextureMix <= 0.001) return toneMapWallpaper(previous, viewportUv);
|
||||
if (uTextureMix >= 0.999) return toneMapWallpaper(current, viewportUv);
|
||||
if (uTextureMix <= 0.001) {
|
||||
return toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure);
|
||||
}
|
||||
if (uTextureMix >= 0.999) {
|
||||
return toneMapWallpaper(current, viewportUv, uWallpaperExposure);
|
||||
}
|
||||
|
||||
return mix(toneMapWallpaper(previous, viewportUv), toneMapWallpaper(current, viewportUv), uTextureMix);
|
||||
vec3 previousTone = toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure);
|
||||
vec3 currentTone = toneMapWallpaper(current, viewportUv, uWallpaperExposure);
|
||||
|
||||
return mix(previousTone, currentTone, uTextureMix);
|
||||
}
|
||||
|
||||
vec3 sampleChromatic(vec2 uv, float separation) {
|
||||
@@ -520,7 +596,7 @@ vec3 sampleHighQualityDiffuse(vec2 uv, vec2 axis, float radius) {
|
||||
}
|
||||
|
||||
float getContentProtection(vec2 sourceUv) {
|
||||
if (uQuality < 0.5 || uHasWallpaperTexture < 0.5) return 1.0;
|
||||
if (uQuality < 0.5 || uHasWallpaperTexture < 0.5 || uAppearance > 1.5) return 1.0;
|
||||
|
||||
vec2 sourceTexel = max(uCoverScale, vec2(0.0001)) / max(uVisibleViewportSize, vec2(1.0));
|
||||
vec3 horizontalStart = sampleWallpaper(sourceUv - vec2(sourceTexel.x * 2.5, 0.0));
|
||||
@@ -661,7 +737,7 @@ void main() {
|
||||
pointerEnergy,
|
||||
max(min(1.0, trailEnergy) * 0.68, max(temporalEnergy * 0.76, wakeEnergy * 0.82))
|
||||
), 0.0, 1.0);
|
||||
float staticLens = 0.00018 + edgeResponse * mix(0.0011, 0.0017, uQuality);
|
||||
float staticLens = 0.00008 + edgeResponse * mix(0.00045, 0.00072, uQuality);
|
||||
float pointerStrength = mix(mix(0.0055, 0.008, uQuality), mix(0.0085, 0.012, uQuality), frosted);
|
||||
float trailStrength = mix(mix(0.78, 1.08, uQuality), mix(0.96, 1.3, uQuality), frosted);
|
||||
float temporalStrength = mix(0.032, 0.042, frosted) * uQuality * (1.0 + flowSurfaceDetail * 0.5);
|
||||
@@ -707,11 +783,16 @@ void main() {
|
||||
dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction);
|
||||
vec2 refraction = staticRefraction + dynamicRefraction;
|
||||
vec2 sourceUv = coverUv(vUv + refraction);
|
||||
float separation = edge * mix(0.00072, 0.0013, uQuality) * mix(1.0, 0.58, frosted);
|
||||
vec3 refracted = sampleChromatic(sourceUv, separation);
|
||||
float separation = edge * mix(0.00024, 0.00055, uQuality) * mix(1.0, 0.58, frosted);
|
||||
float usesPrefilteredFrost = frosted * uHasFrostedTexture;
|
||||
vec3 refracted = usesPrefilteredFrost > 0.5
|
||||
? sampleWallpaper(sourceUv)
|
||||
: sampleChromatic(sourceUv, separation);
|
||||
float detailSeparation = separation * mix(1.45, 2.35, uQuality);
|
||||
vec3 detailed = sampleChromatic(sourceUv, detailSeparation);
|
||||
refracted = mix(refracted, detailed, mix(0.12, 0.32, uQuality) * (1.0 - frosted));
|
||||
vec3 detailed = usesPrefilteredFrost > 0.5
|
||||
? refracted
|
||||
: sampleChromatic(sourceUv, detailSeparation);
|
||||
refracted = mix(refracted, detailed, mix(0.06, 0.16, uQuality) * (1.0 - frosted));
|
||||
vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular;
|
||||
float diffusionRadius =
|
||||
mix(0.0022, 0.0038, uQuality) *
|
||||
@@ -720,8 +801,12 @@ void main() {
|
||||
materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
|
||||
flowSurfaceDetail * dynamicMask * 0.38
|
||||
);
|
||||
float frostedDensity = frosted * (1.0 - smoothstep(0.25, 0.9, uTransparency));
|
||||
diffusionRadius *= 1.0 + frostedDensity * mix(1.15, 1.55, uQuality);
|
||||
vec3 diffused;
|
||||
if (uQuality > 0.5) {
|
||||
if (usesPrefilteredFrost > 0.5) {
|
||||
diffused = refracted;
|
||||
} else if (uQuality > 0.5) {
|
||||
diffused = sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||
} else {
|
||||
diffused = sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||
@@ -729,9 +814,9 @@ void main() {
|
||||
refracted = mix(refracted, diffused, frosted);
|
||||
float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
|
||||
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
|
||||
float transmissionOffset = (uTransmissionStrength - 0.5) * 2.0;
|
||||
float transmissionOffset = min(uTransmissionStrength - 1.0, 0.0);
|
||||
if (transmissionOffset < 0.0) {
|
||||
float dimming = mix(0.18, 0.22, uQuality) * mix(1.0, 0.65, frosted);
|
||||
float dimming = mix(0.14, 0.18, uQuality) * mix(1.0, 0.65, frosted);
|
||||
refracted *= 1.0 + transmissionOffset * dimming;
|
||||
}
|
||||
refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
|
||||
@@ -748,7 +833,9 @@ void main() {
|
||||
float edgeHighlightMix = 0.12;
|
||||
float causticHighlightMix = 0.075;
|
||||
float liquidPresence = clamp(materialEnergy, 0.0, 1.0);
|
||||
float materialAlpha = uTransparency * mix(0.84, 1.0, liquidPresence);
|
||||
// 只有局部动态覆盖可以提高壁纸替换权,静止像素继续遵循用户设置的通透度。
|
||||
float opticalCoverage = smoothstep(0.02, 0.55, liquidPresence);
|
||||
float materialAlpha = mix(uTransparency * 0.26, 0.92, opticalCoverage);
|
||||
float proceduralEdgeAlpha = 0.14;
|
||||
float proceduralCausticAlpha = 0.075;
|
||||
|
||||
@@ -756,14 +843,15 @@ void main() {
|
||||
highlight = vec3(0.94, 0.97, 1.0);
|
||||
edgeHighlightMix = 0.15;
|
||||
causticHighlightMix = 0.042;
|
||||
materialAlpha = mix(0.78, 0.94, uTransparency) * mix(0.9, 1.0, liquidPresence);
|
||||
float frostedBaseAlpha = mix(0.78, 0.94, uTransparency) * 0.9;
|
||||
materialAlpha = mix(frostedBaseAlpha, 0.94, opticalCoverage);
|
||||
proceduralEdgeAlpha = 0.16;
|
||||
proceduralCausticAlpha = 0.045;
|
||||
} else if (uAppearance > 0.5) {
|
||||
highlight = mix(vec3(1.0), uTintColor, 0.72);
|
||||
edgeHighlightMix = 0.17;
|
||||
causticHighlightMix = 0.085;
|
||||
materialAlpha = uTransparency * mix(0.84, 1.0, liquidPresence);
|
||||
materialAlpha = mix(uTransparency * 0.44, 0.92, opticalCoverage);
|
||||
}
|
||||
|
||||
if (uHasWallpaperTexture < 0.5) {
|
||||
@@ -819,9 +907,12 @@ void main() {
|
||||
`
|
||||
|
||||
const SCROLL_STABLE_TAIL_FRAMES = 2
|
||||
const PRESENTATION_RESIZE_SAMPLE_MS = 80
|
||||
const PRESENTATION_RESIZE_REQUIRED_SAMPLES = 2
|
||||
const SURFACE_STABILITY_MAX_FRAMES = 6
|
||||
const SURFACE_STABILITY_REQUIRED_FRAMES = 2
|
||||
const FLOW_BUFFER_SCALE = 0.25
|
||||
const FROST_PREFILTER_SCALE = 0.125
|
||||
const SURFACE_TRANSITION_DURATION_MS = 96
|
||||
const SURFACE_TRANSFORM_TRACKING_MAX_MS = 1000
|
||||
|
||||
@@ -965,12 +1056,17 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let three: ThreeModule | null = null
|
||||
let resources: GlassRendererResources | null = null
|
||||
let flowResources: GlassFlowResources | null = null
|
||||
let frostPrefilterResources: GlassFrostPrefilterResources | null = null
|
||||
let activeTexture: Texture | null = null
|
||||
let activeFrostedTarget: WebGLRenderTarget | null = null
|
||||
let activeTextureHeight = 1
|
||||
let activeTextureWidth = 1
|
||||
let activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||
let previousTexture: Texture | null = null
|
||||
let previousFrostedTarget: WebGLRenderTarget | null = null
|
||||
let previousTextureHeight = 1
|
||||
let previousTextureWidth = 1
|
||||
let previousWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||
let loadVersion = 0
|
||||
let prepareVersion = 0
|
||||
let preparedWallpaper: PreparedWallpaperTexture | null = null
|
||||
@@ -999,6 +1095,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let activeTouchIdentifier: number | null = null
|
||||
let pendingFlowInjection = 0
|
||||
let interactionAnimating = false
|
||||
let presentationResizeCandidate = ''
|
||||
let presentationResizeStableSamples = 0
|
||||
let presentationResizeTimer: number | null = null
|
||||
let scrollAnimationFrame: number | null = null
|
||||
let scrollDirty = false
|
||||
let scrollSurfaceRefreshPending = false
|
||||
@@ -1017,6 +1116,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let surfaceTransformFrame: number | null = null
|
||||
let surfaceTransformTrackingDeadline = 0
|
||||
const transformingSurfaces = new Set<HTMLElement>()
|
||||
let pagePresentationGeometryReady = true
|
||||
let wakeDirection = { x: 0, y: -1 }
|
||||
let contextRecoveryPending = false
|
||||
const presentationSpace = options.surfaceSpace ?? 'fixed'
|
||||
@@ -1095,16 +1195,28 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
backgroundDisposeTimer = null
|
||||
}
|
||||
|
||||
/** 源纹理与磨砂预滤目标共享同一壁纸生命周期,必须成对释放。 */
|
||||
function disposeWallpaperResources(texture: Texture | null, frostedTarget: WebGLRenderTarget | null) {
|
||||
texture?.dispose()
|
||||
frostedTarget?.dispose()
|
||||
}
|
||||
|
||||
/** 释放已完成过渡的旧纹理,并让两个采样槽继续指向同一稳定壁纸。 */
|
||||
function finishWallpaperTransition() {
|
||||
if (!resources || !activeTexture) return
|
||||
|
||||
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
|
||||
if (previousTexture && previousTexture !== activeTexture) {
|
||||
disposeWallpaperResources(previousTexture, previousFrostedTarget)
|
||||
}
|
||||
previousTexture = null
|
||||
previousFrostedTarget = null
|
||||
previousTextureHeight = activeTextureHeight
|
||||
previousTextureWidth = activeTextureWidth
|
||||
previousWallpaperExposure = activeWallpaperExposure
|
||||
resources.uniforms.uPreviousTexture.value = activeTexture
|
||||
resources.uniforms.uPreviousFrostedTexture.value = activeFrostedTarget?.texture ?? activeTexture
|
||||
resources.uniforms.uPreviousCoverScale.value.copy(resources.uniforms.uCoverScale.value)
|
||||
resources.uniforms.uPreviousWallpaperExposure.value = activeWallpaperExposure
|
||||
resources.uniforms.uTextureMix.value = 1
|
||||
cancelWallpaperTransitionFrame()
|
||||
}
|
||||
@@ -1141,6 +1253,91 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
|
||||
}
|
||||
|
||||
/** 释放壁纸准备阶段复用的预滤 shader;活动低通纹理由各自 RenderTarget 单独持有。 */
|
||||
function disposeFrostPrefilterResources() {
|
||||
if (!frostPrefilterResources) return
|
||||
|
||||
frostPrefilterResources.material.dispose()
|
||||
frostPrefilterResources = null
|
||||
}
|
||||
|
||||
/** 为当前 WebGL context 创建一次性低分辨率壁纸预滤管线。 */
|
||||
function getFrostPrefilterResources() {
|
||||
if (frostPrefilterResources) return frostPrefilterResources
|
||||
if (!resources || !three) return null
|
||||
|
||||
const uniforms = {
|
||||
uDirection: { value: new three.Vector2(1, 0) },
|
||||
uTexture: { value: null },
|
||||
uTextureSize: { value: new three.Vector2(1, 1) },
|
||||
}
|
||||
const material = new three.ShaderMaterial({
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
fragmentShader: FROST_PREFILTER_FRAGMENT_SHADER,
|
||||
uniforms,
|
||||
vertexShader: VERTEX_SHADER,
|
||||
})
|
||||
const scene = new three.Scene()
|
||||
const mesh = new three.Mesh(resources.geometry, material)
|
||||
mesh.frustumCulled = false
|
||||
scene.add(mesh)
|
||||
frostPrefilterResources = { material, mesh, scene, uniforms }
|
||||
|
||||
return frostPrefilterResources
|
||||
}
|
||||
|
||||
/** 壁纸上传时执行两次 separable blur,常态只保留最终 1/8 RenderTarget。 */
|
||||
async function createFrostedWallpaperTarget(texture: Texture, width: number, height: number) {
|
||||
if (!resources || !three) return null
|
||||
|
||||
const ownerResources = resources
|
||||
const prefilter = getFrostPrefilterResources()
|
||||
if (!prefilter) return null
|
||||
|
||||
const targetWidth = Math.max(1, Math.round(width * FROST_PREFILTER_SCALE))
|
||||
const targetHeight = Math.max(1, Math.round(height * FROST_PREFILTER_SCALE))
|
||||
const createTarget = () =>
|
||||
new three!.WebGLRenderTarget(targetWidth, targetHeight, {
|
||||
depthBuffer: false,
|
||||
magFilter: three!.LinearFilter,
|
||||
minFilter: three!.LinearFilter,
|
||||
stencilBuffer: false,
|
||||
})
|
||||
const intermediateTarget = createTarget()
|
||||
const outputTarget = createTarget()
|
||||
const previousTarget = ownerResources.renderer.getRenderTarget()
|
||||
|
||||
try {
|
||||
ownerResources.renderer.initTexture(texture)
|
||||
await ownerResources.renderer.compileAsync(prefilter.scene, ownerResources.camera)
|
||||
if (resources !== ownerResources) {
|
||||
outputTarget.dispose()
|
||||
return null
|
||||
}
|
||||
|
||||
prefilter.uniforms.uDirection.value.set(1, 0)
|
||||
prefilter.uniforms.uTexture.value = texture
|
||||
prefilter.uniforms.uTextureSize.value.set(width, height)
|
||||
ownerResources.renderer.setRenderTarget(intermediateTarget)
|
||||
ownerResources.renderer.render(prefilter.scene, ownerResources.camera)
|
||||
|
||||
prefilter.uniforms.uDirection.value.set(0, 1)
|
||||
prefilter.uniforms.uTexture.value = intermediateTarget.texture
|
||||
prefilter.uniforms.uTextureSize.value.set(targetWidth, targetHeight)
|
||||
ownerResources.renderer.setRenderTarget(outputTarget)
|
||||
ownerResources.renderer.render(prefilter.scene, ownerResources.camera)
|
||||
|
||||
return outputTarget
|
||||
} catch (error) {
|
||||
outputTarget.dispose()
|
||||
throw error
|
||||
} finally {
|
||||
if (resources === ownerResources) ownerResources.renderer.setRenderTarget(previousTarget)
|
||||
intermediateTarget.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** 释放仅由高质量档使用的短时液态位移场。 */
|
||||
function disposeFlowResources() {
|
||||
if (!flowResources) return
|
||||
@@ -1270,6 +1467,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const transitionWeights = outgoingSurface
|
||||
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
|
||||
: { incoming: 1, outgoing: 0 }
|
||||
const pageMotionOpacity =
|
||||
presentationSpace === 'scroll' ? Math.min(1, Math.max(0, toValue(options.pageMotion?.opacity ?? 1))) : 1
|
||||
const pagePresentationWeight = pagePresentationGeometryReady ? pageMotionOpacity : 0
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const surface = normalized[index]
|
||||
@@ -1278,7 +1478,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const radii = surface?.radii ?? [0, 0, 0, 0]
|
||||
uniformRects[index].set(rect[0], rect[1], rect[2], rect[3])
|
||||
uniformRadii[index].set(radii[0], radii[1], radii[2], radii[3])
|
||||
uniformWeights[index] =
|
||||
const surfaceWeight =
|
||||
slot?.role === 'outgoing'
|
||||
? transitionWeights.outgoing
|
||||
: slot?.role === 'active'
|
||||
@@ -1286,6 +1486,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
: slot
|
||||
? 1
|
||||
: 0
|
||||
uniformWeights[index] = surfaceWeight * pagePresentationWeight
|
||||
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
|
||||
}
|
||||
|
||||
@@ -1373,21 +1574,76 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
surfaceStabilityPass < SURFACE_STABILITY_MAX_FRAMES
|
||||
) {
|
||||
surfaceStabilityFrame = requestAnimationFrame(sample)
|
||||
} else if (!pagePresentationGeometryReady) {
|
||||
pagePresentationGeometryReady = true
|
||||
writeSurfaceUniforms(timestamp)
|
||||
renderFrame(timestamp, false)
|
||||
}
|
||||
}
|
||||
|
||||
surfaceStabilityFrame = requestAnimationFrame(sample)
|
||||
}
|
||||
|
||||
/** ResizeObserver 在浏览器绘制前提交新尺寸,避免 CSS 与 WebGL buffer 跨帧失配。 */
|
||||
/**
|
||||
* 页面根的 scrollHeight 可能在 content box 稳定后继续收敛。
|
||||
* 连续两个 80ms 样本一致才允许覆盖已提交的 presentation 首帧。
|
||||
*/
|
||||
function schedulePresentationResizeUpdate() {
|
||||
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
|
||||
const sample = () => {
|
||||
presentationResizeTimer = null
|
||||
if (!resources) return
|
||||
|
||||
const presentation = getPresentationSize()
|
||||
const candidate = `${window.innerWidth},${window.innerHeight},${presentation.width},${presentation.height}`
|
||||
presentationResizeStableSamples =
|
||||
candidate === presentationResizeCandidate ? presentationResizeStableSamples + 1 : 1
|
||||
presentationResizeCandidate = candidate
|
||||
if (presentationResizeStableSamples >= PRESENTATION_RESIZE_REQUIRED_SAMPLES) {
|
||||
resizeRenderer()
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
return
|
||||
}
|
||||
|
||||
presentationResizeTimer = window.setTimeout(sample, PRESENTATION_RESIZE_SAMPLE_MS)
|
||||
}
|
||||
|
||||
presentationResizeTimer = window.setTimeout(sample, PRESENTATION_RESIZE_SAMPLE_MS)
|
||||
}
|
||||
|
||||
/** 共享页面 motion 活跃时,页面几何变化必须在浏览器绘制前完成一次完整 presentation 提交。 */
|
||||
function commitActivePagePresentation(timestamp = performance.now()) {
|
||||
if (
|
||||
!resources ||
|
||||
presentationSpace !== 'scroll' ||
|
||||
!toValue(options.pageMotion?.active ?? false)
|
||||
)
|
||||
return false
|
||||
|
||||
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeTimer = null
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
resizeRenderer()
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
renderFrame(timestamp, false)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** 普通表面尺寸即时更新;页面根尺寸在稳定后覆盖 presentation。 */
|
||||
function handleSurfaceResize(entries: ResizeObserverEntry[]) {
|
||||
if (!resources) return
|
||||
|
||||
const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
|
||||
if (presentationRoot && entries.some(entry => entry.target === presentationRoot)) {
|
||||
resizeRenderer()
|
||||
return
|
||||
}
|
||||
const presentationChanged = presentationRoot && entries.some(entry => entry.target === presentationRoot)
|
||||
if (presentationChanged && commitActivePagePresentation()) return
|
||||
if (presentationChanged) schedulePresentationResizeUpdate()
|
||||
if (!entries.some(entry => entry.target !== presentationRoot)) return
|
||||
|
||||
const timestamp = performance.now()
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
@@ -2054,6 +2310,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
void initializeRenderer(false)
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
if (presentationSpace === 'scroll') schedulePresentationResizeUpdate()
|
||||
else resizeRenderer()
|
||||
}
|
||||
|
||||
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
|
||||
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
|
||||
return mutations.some(
|
||||
@@ -2083,7 +2344,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
surfaceMutationObserver = new MutationObserver(mutations => {
|
||||
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
|
||||
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
|
||||
if (mutationTouchesOpticalSurface(mutations)) scheduleSurfaceStabilityUpdate()
|
||||
if (!mutationTouchesOpticalSurface(mutations)) return
|
||||
|
||||
commitActivePagePresentation()
|
||||
scheduleSurfaceStabilityUpdate()
|
||||
})
|
||||
observeMutationRoot(document.querySelector('.app-wrapper'), true)
|
||||
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
|
||||
@@ -2104,7 +2368,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
window.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
window.addEventListener('touchcancel', handleTouchEnd, { passive: true })
|
||||
}
|
||||
window.addEventListener('resize', resizeRenderer, { passive: true })
|
||||
window.addEventListener('resize', handleWindowResize, { passive: true })
|
||||
window.addEventListener('transitionrun', handleSurfaceTransitionRun, { capture: true, passive: true })
|
||||
window.addEventListener('transitionend', handleSurfaceTransitionEnd, { capture: true, passive: true })
|
||||
window.addEventListener('transitioncancel', handleSurfaceTransitionEnd, { capture: true, passive: true })
|
||||
@@ -2126,7 +2390,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
window.removeEventListener('touchend', handleTouchEnd)
|
||||
window.removeEventListener('touchcancel', handleTouchEnd)
|
||||
}
|
||||
window.removeEventListener('resize', resizeRenderer)
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
window.removeEventListener('transitionrun', handleSurfaceTransitionRun, true)
|
||||
window.removeEventListener('transitionend', handleSurfaceTransitionEnd, true)
|
||||
window.removeEventListener('transitioncancel', handleSurfaceTransitionEnd, true)
|
||||
@@ -2156,6 +2420,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
cancelAnimationFrame(surfaceStabilityFrame)
|
||||
surfaceStabilityFrame = null
|
||||
}
|
||||
if (presentationResizeTimer !== null) {
|
||||
window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeTimer = null
|
||||
}
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
removeEvents()
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
@@ -2180,19 +2450,28 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
pointerSpringVelocityY = 0
|
||||
pendingFlowInjection = 0
|
||||
lastInteractionFrameAt = 0
|
||||
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
|
||||
if (previousTexture && previousTexture !== activeTexture) {
|
||||
disposeWallpaperResources(previousTexture, previousFrostedTarget)
|
||||
}
|
||||
previousTexture = null
|
||||
previousFrostedTarget = null
|
||||
previousTextureHeight = 1
|
||||
previousTextureWidth = 1
|
||||
activeTexture?.dispose()
|
||||
previousWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||
disposeWallpaperResources(activeTexture, activeFrostedTarget)
|
||||
activeTexture = null
|
||||
activeFrostedTarget = null
|
||||
activeTextureHeight = 1
|
||||
activeTextureWidth = 1
|
||||
preparedWallpaper?.texture.dispose()
|
||||
activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||
if (preparedWallpaper) {
|
||||
disposeWallpaperResources(preparedWallpaper.texture, preparedWallpaper.frostedTarget)
|
||||
}
|
||||
preparedWallpaper = null
|
||||
preparedWallpaperUrl.value = ''
|
||||
|
||||
disposeFlowResources()
|
||||
disposeFrostPrefilterResources()
|
||||
if (resources) {
|
||||
resources.geometry.dispose()
|
||||
resources.material.dispose()
|
||||
@@ -2237,9 +2516,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
/** 在同一 renderer 内交接新旧纹理;只有真实壁纸轮换才启用双纹理时钟。 */
|
||||
function activateLoadedTexture(texture: Texture, width: number, height: number, hasWallpaperTexture: boolean) {
|
||||
function activateLoadedTexture(
|
||||
texture: Texture,
|
||||
frostedTarget: WebGLRenderTarget | null,
|
||||
width: number,
|
||||
height: number,
|
||||
hasWallpaperTexture: boolean,
|
||||
toneProfile: GlassWallpaperToneProfile,
|
||||
) {
|
||||
if (!resources) {
|
||||
texture.dispose()
|
||||
disposeWallpaperResources(texture, frostedTarget)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2250,15 +2536,25 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
cancelWallpaperTransitionFrame()
|
||||
|
||||
if (hasActiveTransition && activeTexture) {
|
||||
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
|
||||
if (previousTexture && previousTexture !== activeTexture) {
|
||||
disposeWallpaperResources(previousTexture, previousFrostedTarget)
|
||||
}
|
||||
previousTexture = activeTexture
|
||||
previousFrostedTarget = activeFrostedTarget
|
||||
previousTextureHeight = activeTextureHeight
|
||||
previousTextureWidth = activeTextureWidth
|
||||
previousWallpaperExposure = activeWallpaperExposure
|
||||
activeTexture = texture
|
||||
activeFrostedTarget = frostedTarget
|
||||
activeTextureHeight = height
|
||||
activeTextureWidth = width
|
||||
activeWallpaperExposure = toneProfile.exposure
|
||||
resources.uniforms.uPreviousTexture.value = previousTexture
|
||||
resources.uniforms.uPreviousFrostedTexture.value = previousFrostedTarget?.texture ?? previousTexture
|
||||
resources.uniforms.uTexture.value = activeTexture
|
||||
resources.uniforms.uFrostedTexture.value = activeFrostedTarget?.texture ?? activeTexture
|
||||
resources.uniforms.uPreviousWallpaperExposure.value = previousWallpaperExposure
|
||||
resources.uniforms.uWallpaperExposure.value = activeWallpaperExposure
|
||||
resources.uniforms.uTextureMix.value = getGlassWallpaperTransitionProgress(
|
||||
performance.now() - toValue(options.transitionStartedAt ?? 0),
|
||||
toValue(options.transitionDuration ?? 0),
|
||||
@@ -2266,21 +2562,32 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
syncCoverScale()
|
||||
scheduleWallpaperTransition()
|
||||
} else {
|
||||
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
|
||||
activeTexture?.dispose()
|
||||
if (previousTexture && previousTexture !== activeTexture) {
|
||||
disposeWallpaperResources(previousTexture, previousFrostedTarget)
|
||||
}
|
||||
disposeWallpaperResources(activeTexture, activeFrostedTarget)
|
||||
previousTexture = null
|
||||
previousFrostedTarget = null
|
||||
activeTexture = texture
|
||||
activeFrostedTarget = frostedTarget
|
||||
activeTextureHeight = height
|
||||
activeTextureWidth = width
|
||||
activeWallpaperExposure = toneProfile.exposure
|
||||
previousTextureHeight = height
|
||||
previousTextureWidth = width
|
||||
previousWallpaperExposure = toneProfile.exposure
|
||||
resources.uniforms.uPreviousTexture.value = texture
|
||||
resources.uniforms.uPreviousFrostedTexture.value = frostedTarget?.texture ?? texture
|
||||
resources.uniforms.uTexture.value = texture
|
||||
resources.uniforms.uFrostedTexture.value = frostedTarget?.texture ?? texture
|
||||
resources.uniforms.uPreviousWallpaperExposure.value = toneProfile.exposure
|
||||
resources.uniforms.uWallpaperExposure.value = toneProfile.exposure
|
||||
resources.uniforms.uTextureMix.value = 1
|
||||
syncCoverScale()
|
||||
}
|
||||
|
||||
resources.uniforms.uHasWallpaperTexture.value = hasWallpaperTexture ? 1 : 0
|
||||
resources.uniforms.uHasFrostedTexture.value = hasWallpaperTexture && frostedTarget ? 1 : 0
|
||||
}
|
||||
|
||||
/** 解码并按当前质量预算缩放壁纸,不改变当前可见纹理。 */
|
||||
@@ -2299,7 +2606,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
texture.generateMipmaps = false
|
||||
texture.minFilter = three.LinearFilter
|
||||
texture.magFilter = three.LinearFilter
|
||||
return { hasWallpaperTexture: false, height: 1, texture, width: 1 }
|
||||
return {
|
||||
frostedTarget: null,
|
||||
hasWallpaperTexture: false,
|
||||
height: 1,
|
||||
texture,
|
||||
toneProfile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
|
||||
width: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const loader = new three.TextureLoader()
|
||||
@@ -2333,10 +2647,19 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
texture.generateMipmaps = false
|
||||
texture.minFilter = three.LinearFilter
|
||||
texture.magFilter = three.LinearFilter
|
||||
let frostedTarget: WebGLRenderTarget | null = null
|
||||
try {
|
||||
frostedTarget = await createFrostedWallpaperTarget(texture, textureWidth, textureHeight)
|
||||
} catch (error) {
|
||||
// 预滤属于磨砂优化;失败时保留源纹理并退回既有扩散采样,不能拖垮其他材质。
|
||||
console.warn('玻璃磨砂壁纸预滤失败,继续使用实时扩散采样:', error)
|
||||
}
|
||||
return {
|
||||
frostedTarget,
|
||||
hasWallpaperTexture: true,
|
||||
height: textureHeight,
|
||||
texture,
|
||||
toneProfile: analyzeGlassWallpaperTone(image, sourceWidth, sourceHeight),
|
||||
width: textureWidth,
|
||||
}
|
||||
}
|
||||
@@ -2344,7 +2667,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
/** 提前准备下一张纹理;失败不会影响当前活动纹理。 */
|
||||
async function prepareWallpaper(url: string) {
|
||||
const version = ++prepareVersion
|
||||
preparedWallpaper?.texture.dispose()
|
||||
if (preparedWallpaper) {
|
||||
disposeWallpaperResources(preparedWallpaper.texture, preparedWallpaper.frostedTarget)
|
||||
}
|
||||
preparedWallpaper = null
|
||||
preparedWallpaperUrl.value = ''
|
||||
if (!url || !resources || url === toValue(options.wallpaperUrl)) {
|
||||
@@ -2356,7 +2681,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const prepared = await createWallpaperTexture(url)
|
||||
if (!prepared) return
|
||||
if (version !== prepareVersion || !resources) {
|
||||
prepared.texture.dispose()
|
||||
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2379,11 +2704,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
preparedWallpaperUrl.value = ''
|
||||
}
|
||||
if (version !== loadVersion || !resources) {
|
||||
prepared.texture.dispose()
|
||||
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||
return
|
||||
}
|
||||
|
||||
activateLoadedTexture(prepared.texture, prepared.width, prepared.height, prepared.hasWallpaperTexture)
|
||||
activateLoadedTexture(
|
||||
prepared.texture,
|
||||
prepared.frostedTarget,
|
||||
prepared.width,
|
||||
prepared.height,
|
||||
prepared.hasWallpaperTexture,
|
||||
prepared.toneProfile,
|
||||
)
|
||||
await resources.renderer.compileAsync(resources.scene, resources.camera)
|
||||
if (version !== loadVersion || !resources) return
|
||||
|
||||
@@ -2427,6 +2759,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
uFlowTexture: { value: null },
|
||||
uFlowStrength: { value: getFlowStrengthScale() },
|
||||
uHasFlowTexture: { value: 0 },
|
||||
uHasFrostedTexture: { value: 0 },
|
||||
uHasWallpaperTexture: { value: 0 },
|
||||
uMotion: { value: 0 },
|
||||
uMotionExpansion: { value: getMotionExpansion() },
|
||||
@@ -2435,6 +2768,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
uPointerVelocity: { value: new three.Vector2(0, 0) },
|
||||
uPresentationSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) },
|
||||
uPreviousCoverScale: { value: new three.Vector2(1, 1) },
|
||||
uPreviousWallpaperExposure: { value: DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure },
|
||||
uQuality: { value: toValue(options.quality) === 'high' ? 1 : 0 },
|
||||
uReflectionStrength: { value: getReflectionStrengthScale() },
|
||||
uRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
|
||||
@@ -2443,8 +2777,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
|
||||
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
|
||||
uPreviousTexture: { value: null },
|
||||
uPreviousFrostedTexture: { value: null },
|
||||
uTexture: { value: null },
|
||||
uFrostedTexture: { value: null },
|
||||
uTextureMix: { value: 1 },
|
||||
uWallpaperExposure: { value: DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure },
|
||||
uTintColor: { value: new three.Color(toValue(options.tintColor)) },
|
||||
uTransparency: { value: getTransparency() },
|
||||
uTransmissionStrength: {
|
||||
@@ -2623,10 +2960,31 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => toValue(options.pageMotion?.revision ?? 0),
|
||||
() => {
|
||||
if (!resources || presentationSpace !== 'scroll') return
|
||||
|
||||
const timestamp = performance.now()
|
||||
// 页面入场期间内容高度与 canvas CSS 尺寸可能同帧变化;presentation 必须先于表面和清屏提交。
|
||||
resizeRenderer()
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
renderFrame(timestamp, false)
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => toValue(options.routeKey),
|
||||
async (routeKey, previousRouteKey) => {
|
||||
const previousProfile = getRenderProfile(previousRouteKey ?? '')
|
||||
if (resources && presentationSpace === 'scroll' && options.pageMotion) {
|
||||
pagePresentationGeometryReady = false
|
||||
const timestamp = performance.now()
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
renderFrame(timestamp, false)
|
||||
}
|
||||
await nextTick()
|
||||
if (resources) {
|
||||
const nextProfile = getRenderProfile(routeKey)
|
||||
resizeRenderer()
|
||||
@@ -2636,9 +2994,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
return
|
||||
}
|
||||
}
|
||||
scheduleSurfaceUpdate()
|
||||
if (presentationSpace === 'scroll' && options.pageMotion) scheduleSurfaceStabilityUpdate()
|
||||
else scheduleSurfaceUpdate()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
|
||||
224
src/composables/usePagePresentationMotion.ts
Normal file
224
src/composables/usePagePresentationMotion.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { readonly, ref, type Ref } from 'vue'
|
||||
|
||||
export const PAGE_PRESENTATION_MOTION_DURATION_MS = 180
|
||||
export const PAGE_PRESENTATION_MOTION_START_OPACITY = 0.88
|
||||
export const PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y = 4
|
||||
export const PAGE_PRESENTATION_LAYOUT_STABLE_MS = 120
|
||||
export const PAGE_PRESENTATION_LAYOUT_HOLD_MAX_MS = 480
|
||||
|
||||
/** renderer 只读取同一帧已经提交到 DOM 的页面呈现状态。 */
|
||||
export interface PagePresentationMotionReader {
|
||||
/** 页面是否处于共享呈现事务中。 */
|
||||
active: Readonly<Ref<boolean>>
|
||||
/** 当前页面材质与 DOM 共同使用的透明度。 */
|
||||
opacity: Readonly<Ref<number>>
|
||||
/** 每次 DOM motion 样式提交后递增,renderer 据此在同一帧刷新表面。 */
|
||||
revision: Readonly<Ref<number>>
|
||||
}
|
||||
|
||||
const active = ref(false)
|
||||
const epoch = ref(0)
|
||||
const opacity = ref(1)
|
||||
const progress = ref(1)
|
||||
const revision = ref(0)
|
||||
const routeKey = ref('')
|
||||
const translateY = ref(0)
|
||||
let animationFrame: number | null = null
|
||||
let layoutHoldStartedAt = 0
|
||||
let layoutStableSince = 0
|
||||
let layoutSignature = ''
|
||||
let startedAt = 0
|
||||
|
||||
function sampleBezier(time: number, start: number, end: number) {
|
||||
const inverse = 1 - time
|
||||
|
||||
return 3 * inverse * inverse * time * start + 3 * inverse * time * time * end + time * time * time
|
||||
}
|
||||
|
||||
/** 计算玻璃页面统一使用的 `cubic-bezier(0.2, 0.8, 0.2, 1)` 进度。 */
|
||||
export function getPagePresentationMotionProgress(elapsed: number, duration = PAGE_PRESENTATION_MOTION_DURATION_MS) {
|
||||
if (duration <= 0 || elapsed >= duration) return 1
|
||||
if (elapsed <= 0) return 0
|
||||
|
||||
const target = elapsed / duration
|
||||
let lower = 0
|
||||
let upper = 1
|
||||
let parameter = target
|
||||
|
||||
for (let iteration = 0; iteration < 10; iteration += 1) {
|
||||
parameter = (lower + upper) * 0.5
|
||||
if (sampleBezier(parameter, 0.2, 0.2) < target) lower = parameter
|
||||
else upper = parameter
|
||||
}
|
||||
|
||||
return sampleBezier(parameter, 0.8, 1)
|
||||
}
|
||||
|
||||
function clearDocumentMotionState() {
|
||||
const root = document.documentElement
|
||||
delete root.dataset.pagePresentationMotion
|
||||
root.style.removeProperty('--mp-page-motion-opacity')
|
||||
root.style.removeProperty('--mp-page-motion-translate-y')
|
||||
}
|
||||
|
||||
/** 先提交 DOM 样式,再发布 revision,保证 renderer 读取到同一帧的真实矩形。 */
|
||||
function applyMotionFrame(nextProgress: number) {
|
||||
const root = document.documentElement
|
||||
const nextOpacity =
|
||||
PAGE_PRESENTATION_MOTION_START_OPACITY + (1 - PAGE_PRESENTATION_MOTION_START_OPACITY) * nextProgress
|
||||
const nextTranslateY = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y * (1 - nextProgress)
|
||||
|
||||
root.dataset.pagePresentationMotion = 'active'
|
||||
root.style.setProperty('--mp-page-motion-opacity', nextOpacity.toFixed(4))
|
||||
root.style.setProperty('--mp-page-motion-translate-y', `${nextTranslateY.toFixed(3)}px`)
|
||||
opacity.value = nextOpacity
|
||||
progress.value = nextProgress
|
||||
translateY.value = nextTranslateY
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
/** 布局门关闭时 DOM 与 renderer 都不暴露尚未稳定的页面几何。 */
|
||||
function applyLayoutHoldFrame() {
|
||||
const root = document.documentElement
|
||||
|
||||
root.dataset.pagePresentationMotion = 'active'
|
||||
root.style.setProperty('--mp-page-motion-opacity', '0')
|
||||
root.style.setProperty('--mp-page-motion-translate-y', `${PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y}px`)
|
||||
opacity.value = 0
|
||||
progress.value = 0
|
||||
translateY.value = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
function getLayoutSignature(root: HTMLElement) {
|
||||
return `${root.offsetWidth},${root.offsetHeight},${root.scrollWidth},${root.scrollHeight}`
|
||||
}
|
||||
|
||||
function beginReveal(timestamp: number, motionEpoch: number) {
|
||||
if (!active.value || epoch.value !== motionEpoch) return
|
||||
|
||||
startedAt = timestamp
|
||||
applyMotionFrame(0)
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
|
||||
}
|
||||
|
||||
/** 页面根持续稳定后才开始 reveal;上限避免持续布局页面永久不可见。 */
|
||||
function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElement) {
|
||||
if (!active.value || epoch.value !== motionEpoch) return
|
||||
animationFrame = null
|
||||
|
||||
const nextSignature = getLayoutSignature(root)
|
||||
if (nextSignature !== layoutSignature) {
|
||||
layoutSignature = nextSignature
|
||||
layoutStableSince = timestamp
|
||||
}
|
||||
|
||||
if (
|
||||
timestamp - layoutStableSince >= PAGE_PRESENTATION_LAYOUT_STABLE_MS ||
|
||||
timestamp - layoutHoldStartedAt >= PAGE_PRESENTATION_LAYOUT_HOLD_MAX_MS
|
||||
) {
|
||||
beginReveal(timestamp, motionEpoch)
|
||||
return
|
||||
}
|
||||
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp =>
|
||||
sampleLayoutHold(nextTimestamp, motionEpoch, root),
|
||||
)
|
||||
}
|
||||
|
||||
function settleMotion() {
|
||||
active.value = false
|
||||
opacity.value = 1
|
||||
progress.value = 1
|
||||
translateY.value = 0
|
||||
clearDocumentMotionState()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
const needsRendererCommit =
|
||||
active.value ||
|
||||
opacity.value !== 1 ||
|
||||
translateY.value !== 0 ||
|
||||
document.documentElement.dataset.pagePresentationMotion === 'active'
|
||||
|
||||
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
|
||||
animationFrame = null
|
||||
if (needsRendererCommit) epoch.value += 1
|
||||
settleMotion()
|
||||
if (needsRendererCommit) revision.value += 1
|
||||
}
|
||||
|
||||
function renderFrame(timestamp: number, motionEpoch: number) {
|
||||
if (!active.value || epoch.value !== motionEpoch) return
|
||||
animationFrame = null
|
||||
|
||||
const nextProgress = getPagePresentationMotionProgress(timestamp - startedAt)
|
||||
applyMotionFrame(nextProgress)
|
||||
if (nextProgress < 1) {
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
|
||||
return
|
||||
}
|
||||
|
||||
settleMotion()
|
||||
}
|
||||
|
||||
/**
|
||||
* 玻璃主题由共享控制器接管页面入场;其他主题继续使用既有 CSS keyframe。
|
||||
* 返回 true 表示本次路由变化已经处理,包括 reduced-motion 的即时提交。
|
||||
*/
|
||||
function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
|
||||
if (document.documentElement.dataset.theme !== 'glass') {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
|
||||
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
|
||||
animationFrame = null
|
||||
epoch.value += 1
|
||||
const motionEpoch = epoch.value
|
||||
routeKey.value = nextRouteKey
|
||||
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
settleMotion()
|
||||
revision.value += 1
|
||||
return true
|
||||
}
|
||||
|
||||
active.value = true
|
||||
const timestamp = performance.now()
|
||||
if (layoutRoot) {
|
||||
layoutHoldStartedAt = timestamp
|
||||
layoutStableSince = timestamp
|
||||
layoutSignature = getLayoutSignature(layoutRoot)
|
||||
applyLayoutHoldFrame()
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp =>
|
||||
sampleLayoutHold(nextTimestamp, motionEpoch, layoutRoot),
|
||||
)
|
||||
} else {
|
||||
beginReveal(timestamp, motionEpoch)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const reader: PagePresentationMotionReader = {
|
||||
active: readonly(active),
|
||||
opacity: readonly(opacity),
|
||||
revision: readonly(revision),
|
||||
}
|
||||
|
||||
/** 提供默认布局与 glass renderer 共享的短时页面呈现事务。 */
|
||||
export function usePagePresentationMotion() {
|
||||
return {
|
||||
active: readonly(active),
|
||||
cancel,
|
||||
epoch: readonly(epoch),
|
||||
opacity: reader.opacity,
|
||||
progress: readonly(progress),
|
||||
reader,
|
||||
revision: reader.revision,
|
||||
routeKey: readonly(routeKey),
|
||||
start,
|
||||
translateY: readonly(translateY),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import DefaultLayout from './default/components/DefaultLayout.vue'
|
||||
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||
|
||||
const route = useRoute()
|
||||
const pagePresentationMotion = usePagePresentationMotion()
|
||||
|
||||
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
|
||||
const routeCacheKey = computed(() => {
|
||||
@@ -16,6 +18,7 @@ const routeCacheKey = computed(() => {
|
||||
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
|
||||
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
|
||||
const isPageEntering = ref(false)
|
||||
const pageRouteRef = ref<HTMLElement | null>(null)
|
||||
let pageMotionTimer: number | null = null
|
||||
let pageMotionFrame: number | null = null
|
||||
|
||||
@@ -32,9 +35,11 @@ function playPageEnterMotion() {
|
||||
}
|
||||
|
||||
isPageEntering.value = false
|
||||
if (pagePresentationMotion.start(routeTransitionKey.value, pageRouteRef.value)) return
|
||||
|
||||
pageMotionFrame = window.requestAnimationFrame(() => {
|
||||
isPageEntering.value = true
|
||||
pageMotionFrame = null
|
||||
isPageEntering.value = true
|
||||
pageMotionTimer = window.setTimeout(() => {
|
||||
isPageEntering.value = false
|
||||
pageMotionTimer = null
|
||||
@@ -49,13 +54,14 @@ onMounted(playPageEnterMotion)
|
||||
onBeforeUnmount(() => {
|
||||
if (pageMotionTimer) window.clearTimeout(pageMotionTimer)
|
||||
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
|
||||
pagePresentationMotion.cancel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<div class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
|
||||
<div ref="pageRouteRef" class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
|
||||
<keep-alive :max="24">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
|
||||
</keep-alive>
|
||||
|
||||
@@ -44,6 +44,7 @@ const mocks = vi.hoisted(() => {
|
||||
grid,
|
||||
gridInit: vi.fn<(options: unknown, element: unknown) => unknown>(() => grid),
|
||||
openSharedDialog: vi.fn(),
|
||||
themeName: undefined as unknown as { value: string },
|
||||
useDynamicButton: vi.fn(),
|
||||
}
|
||||
})
|
||||
@@ -76,10 +77,12 @@ vi.mock('@/api', () => ({
|
||||
vi.mock('vuetify', async importOriginal => {
|
||||
const { ref } = await import('vue')
|
||||
mocks.displayWidth = ref(1512)
|
||||
mocks.themeName = ref('light')
|
||||
|
||||
return {
|
||||
...(await importOriginal<typeof import('vuetify')>()),
|
||||
useDisplay: () => ({ width: mocks.displayWidth }),
|
||||
useTheme: () => ({ global: { name: mocks.themeName } }),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -184,6 +187,7 @@ describe('dashboard page initial layout', () => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.displayWidth.value = 1512
|
||||
mocks.themeName.value = 'light'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||
})
|
||||
|
||||
@@ -210,9 +214,9 @@ describe('dashboard page initial layout', () => {
|
||||
|
||||
expect(screen.getAllByTestId('dashboard-item')).toHaveLength(1)
|
||||
expect(screen.getByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'systemInfo')
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement))
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: true }), expect.any(HTMLElement))
|
||||
expect(container.querySelector('.dashboard-grid')).not.toHaveClass('is-revealed')
|
||||
await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true))
|
||||
await waitFor(() => expect(container.querySelector('.dashboard-grid')).toHaveClass('is-revealed'))
|
||||
|
||||
remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } })
|
||||
remoteProfile.resolve({
|
||||
@@ -228,6 +232,26 @@ describe('dashboard page initial layout', () => {
|
||||
await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('disables automatic grid transitions only while browsing with the glass theme', async () => {
|
||||
mocks.themeName.value = 'glass'
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') {
|
||||
return { data: {} }
|
||||
}
|
||||
if (url === '/user/config/Dashboard') return { data: {} }
|
||||
if (url === '/plugin/dashboard/meta') return []
|
||||
throw new Error('Unexpected GET ' + url)
|
||||
})
|
||||
|
||||
await renderDashboard()
|
||||
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement))
|
||||
await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(false))
|
||||
|
||||
await fireEvent.click(document.querySelector('.compact-fab--primary') as HTMLElement)
|
||||
await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('keeps the upstream progressive default while an uncached remote profile is loading', async () => {
|
||||
const remoteOrder = deferred<unknown>()
|
||||
const remoteProfile = deferred<unknown>()
|
||||
@@ -241,7 +265,7 @@ describe('dashboard page initial layout', () => {
|
||||
await renderDashboard()
|
||||
|
||||
expect(screen.getAllByTestId('dashboard-item')).toHaveLength(10)
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement))
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: true }), expect.any(HTMLElement))
|
||||
|
||||
remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } })
|
||||
remoteProfile.resolve({
|
||||
@@ -746,7 +770,7 @@ describe('dashboard page initial layout', () => {
|
||||
await waitFor(() => expect(mocks.grid.column).toHaveBeenCalledWith(1, 'list'))
|
||||
await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(10))
|
||||
expect(mocks.grid.setAnimation).toHaveBeenCalledWith(false)
|
||||
expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true)
|
||||
expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([
|
||||
'storage',
|
||||
'mediaStatistic',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { usePWA } from '@/composables/usePWA'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
|
||||
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||||
@@ -24,6 +24,7 @@ const { t } = useI18n()
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const display = useDisplay()
|
||||
const vuetifyTheme = useTheme()
|
||||
const userStore = useUserStore()
|
||||
const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
|
||||
@@ -97,9 +98,6 @@ interface DashboardGridItem {
|
||||
// 是否处于仪表板布局编辑模式
|
||||
const isLayoutEditing = ref(false)
|
||||
|
||||
// 首次布局完成后触发轻量整体入场,不延迟或隐藏渐进渲染内容。
|
||||
const isDashboardGridRevealed = ref(false)
|
||||
|
||||
// 是否发送请求的总开关
|
||||
const isRequest = ref(true)
|
||||
|
||||
@@ -148,7 +146,6 @@ let dashboardGridContentObserver: ResizeObserver | null = null
|
||||
let dashboardGridContentResizeFrame: number | null = null
|
||||
let dashboardGridResizeRefreshFrame: number | null = null
|
||||
let dashboardGridAnimationFrame: number | null = null
|
||||
let dashboardGridEntranceFrame: number | null = null
|
||||
let dashboardRevealFrame: number | null = null
|
||||
let isDashboardRevealPending = false
|
||||
let dashboardProfileSaveQueue = Promise.resolve()
|
||||
@@ -389,16 +386,6 @@ function scheduleDashboardReveal() {
|
||||
})
|
||||
}
|
||||
|
||||
// 首次 GridStack 坐标提交后的下一帧立即入场,不等待卡片内部异步数据。
|
||||
function scheduleDashboardGridEntrance() {
|
||||
if (isDashboardGridRevealed.value || dashboardGridEntranceFrame !== null || typeof window === 'undefined') return
|
||||
|
||||
dashboardGridEntranceFrame = requestAnimationFrame(() => {
|
||||
dashboardGridEntranceFrame = null
|
||||
isDashboardGridRevealed.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 程序化批量布局不播放中间态;稳定后恢复用户拖拽、缩放和让位动画。
|
||||
function pauseDashboardGridAnimation() {
|
||||
if (dashboardGridAnimationFrame !== null) {
|
||||
@@ -408,15 +395,21 @@ function pauseDashboardGridAnimation() {
|
||||
dashboardGrid.value?.setAnimation(false)
|
||||
}
|
||||
|
||||
// 动画恢复延后一帧,确保 GridStack 已提交最终坐标后才重新响应交互。
|
||||
// 玻璃浏览态直接提交自动布局,普通主题和显式编辑交互保留 GridStack 过渡。
|
||||
function shouldAnimateDashboardGrid(editable = isLayoutEditing.value) {
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
|
||||
return !reduceMotion && (editable || vuetifyTheme.global.name.value !== 'glass')
|
||||
}
|
||||
|
||||
// 批量布局同步期间暂停几何过渡,稳定后恢复浏览与编辑状态的卡片动画。
|
||||
function scheduleDashboardGridAnimationResume() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (dashboardGridAnimationFrame !== null) cancelAnimationFrame(dashboardGridAnimationFrame)
|
||||
|
||||
dashboardGridAnimationFrame = requestAnimationFrame(() => {
|
||||
dashboardGridAnimationFrame = null
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
dashboardGrid.value?.setAnimation(!reduceMotion)
|
||||
dashboardGrid.value?.setAnimation(shouldAnimateDashboardGrid())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1192,7 +1185,7 @@ function initializeDashboardGrid() {
|
||||
|
||||
dashboardGrid.value = GridStack.init(
|
||||
{
|
||||
animate: false,
|
||||
animate: shouldAnimateDashboardGrid(),
|
||||
cellHeight: DASHBOARD_GRID_CELL_HEIGHT,
|
||||
column: getDashboardGridColumnsForProfile(dashboardLayoutProfile.value),
|
||||
draggable: {
|
||||
@@ -1222,6 +1215,7 @@ function updateDashboardGridEditableState(editable: boolean) {
|
||||
if (!dashboardGrid.value) return
|
||||
|
||||
dashboardGrid.value.setStatic(!editable)
|
||||
dashboardGrid.value.setAnimation(shouldAnimateDashboardGrid(editable))
|
||||
if (editable) {
|
||||
dashboardGrid.value.enableMove(true)
|
||||
dashboardGrid.value.enableResize(true)
|
||||
@@ -1306,7 +1300,6 @@ async function syncDashboardGrid(resumeAnimation = true) {
|
||||
resizeAutoDashboardItemsToContent()
|
||||
scheduleDashboardReveal()
|
||||
})
|
||||
scheduleDashboardGridEntrance()
|
||||
} finally {
|
||||
isSyncingDashboardGrid.value = false
|
||||
if (resumeAnimation) scheduleDashboardGridAnimationResume()
|
||||
@@ -1547,6 +1540,11 @@ watch(isLayoutEditing, value => {
|
||||
updateDashboardGridEditableState(value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => vuetifyTheme.global.name.value,
|
||||
() => updateDashboardGridEditableState(isLayoutEditing.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
dashboardGridItems,
|
||||
() => {
|
||||
@@ -1664,10 +1662,6 @@ onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(dashboardGridAnimationFrame)
|
||||
dashboardGridAnimationFrame = null
|
||||
}
|
||||
if (dashboardGridEntranceFrame !== null) {
|
||||
cancelAnimationFrame(dashboardGridEntranceFrame)
|
||||
dashboardGridEntranceFrame = null
|
||||
}
|
||||
if (dashboardRevealFrame !== null) {
|
||||
cancelAnimationFrame(dashboardRevealFrame)
|
||||
dashboardRevealFrame = null
|
||||
@@ -1685,7 +1679,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
ref="dashboardGridRef"
|
||||
class="grid-stack dashboard-grid"
|
||||
:class="{ 'is-editing': isLayoutEditing, 'is-revealed': isDashboardGridRevealed }"
|
||||
:class="{ 'is-editing': isLayoutEditing }"
|
||||
>
|
||||
<div
|
||||
v-for="gridItem in dashboardGridItems"
|
||||
@@ -1753,17 +1747,7 @@ onBeforeUnmount(() => {
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.dashboard-grid {
|
||||
opacity: 0.92;
|
||||
pointer-events: auto;
|
||||
transform: translateY(4px);
|
||||
transition:
|
||||
opacity 0.18s ease-out,
|
||||
transform 0.18s ease-out;
|
||||
}
|
||||
|
||||
.dashboard-grid.is-revealed {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.dashboard-grid :deep(.v-card) {
|
||||
@@ -1893,11 +1877,4 @@ onBeforeUnmount(() => {
|
||||
inset-inline-end: -4px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-grid {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -237,39 +237,29 @@ onActivated(async () => {
|
||||
<div>
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="themoviedb">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<TheMovieDbView />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<TheMovieDbView />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="douban">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<DoubanView />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<DoubanView />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="bangumi">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<BangumiView />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<BangumiView />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="anilist">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AniListView />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<AniListView />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<ExtraSourceView :source="item" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<ExtraSourceView :source="item" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
<!-- 快速滚动到顶部按钮 -->
|
||||
|
||||
@@ -55,11 +55,9 @@ useKeepAliveRefresh(async () => {
|
||||
<div v-if="downloaders.length > 0">
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem v-for="item in downloaders" :key="item.name" :value="item.name">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
|
||||
@@ -1317,7 +1317,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-glass-optical-mode="static-material">
|
||||
<!-- 搜索加载状态 -->
|
||||
<VFadeTransition>
|
||||
<div
|
||||
|
||||
@@ -64,15 +64,9 @@ watch(activeTab, markTabVisited, { immediate: true })
|
||||
<div>
|
||||
<VWindow v-model="activeTab" class="settings-content-window disable-tab-transition" :touch="false">
|
||||
<VWindowItem v-for="item in settingTabComponents" :key="item.value" :value="item.value">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-if="visitedTabs.has(item.value)"
|
||||
:active="activeTab === item.value"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<component :is="item.component" v-if="visitedTabs.has(item.value)" :active="activeTab === item.value" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
|
||||
@@ -342,9 +342,7 @@ const subscribeDynamicMenuItems = computed<DynamicButtonMenuItem[] | undefined>(
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
titleKey: subscribeBatchState.value.allSelected
|
||||
? 'subscribe.batchDeselectAll'
|
||||
: 'subscribe.batchSelectAll',
|
||||
titleKey: subscribeBatchState.value.allSelected ? 'subscribe.batchDeselectAll' : 'subscribe.batchSelectAll',
|
||||
icon: subscribeBatchState.value.allSelected ? 'mdi-checkbox-blank-outline' : 'mdi-checkbox-multiple-marked',
|
||||
permission: 'subscribe',
|
||||
disabled: subscribeBatchState.value.totalCount === 0,
|
||||
@@ -442,7 +440,9 @@ useDynamicButton({
|
||||
menuItems: subscribeDynamicMenuItems,
|
||||
permission: 'subscribe',
|
||||
show: computed(
|
||||
() => appMode.value && (subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
|
||||
() =>
|
||||
appMode.value &&
|
||||
(subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -519,37 +519,31 @@ onMounted(() => {
|
||||
<div>
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
|
||||
<VWindowItem value="mysub">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<SubscribeListView
|
||||
ref="subscribeListViewRef"
|
||||
:type="subType"
|
||||
:subid="subId"
|
||||
:keyword="subscribeFilter"
|
||||
:status-filter="subscribeStatusFilter ?? ''"
|
||||
:sort-mode="subscribeSortMode"
|
||||
:sort-by="subscribeSortBy"
|
||||
:active="activeTab === 'mysub'"
|
||||
@update:sort-mode="subscribeSortMode = $event"
|
||||
@update:sort-by="subscribeSortBy = $event"
|
||||
@batch-state-change="handleSubscribeBatchStateChange"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<SubscribeListView
|
||||
ref="subscribeListViewRef"
|
||||
:type="subType"
|
||||
:subid="subId"
|
||||
:keyword="subscribeFilter"
|
||||
:status-filter="subscribeStatusFilter ?? ''"
|
||||
:sort-mode="subscribeSortMode"
|
||||
:sort-by="subscribeSortBy"
|
||||
:active="activeTab === 'mysub'"
|
||||
@update:sort-mode="subscribeSortMode = $event"
|
||||
@update:sort-by="subscribeSortBy = $event"
|
||||
@batch-state-change="handleSubscribeBatchStateChange"
|
||||
/>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="popular">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<SubscribePopularView :type="subType" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<SubscribePopularView :type="subType" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="share">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<SubscribeShareView :keyword="shareKeyword" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<SubscribeShareView :keyword="shareKeyword" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
@@ -722,7 +716,6 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -107,18 +107,14 @@ onMounted(() => {
|
||||
<div>
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
|
||||
<VWindowItem value="list">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<WorkflowListView ref="workflowListViewRef" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<WorkflowListView ref="workflowListViewRef" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="share">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
|
||||
@@ -168,9 +168,9 @@ html[data-theme='glass'] {
|
||||
|
||||
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
|
||||
&[data-glass-appearance='frosted'] {
|
||||
--glass-surface: rgba(255, 255, 255, calc(0.12 - var(--glass-transparency, 0.5) * 0.07));
|
||||
--glass-surface-soft: rgba(255, 255, 255, calc(0.11 - var(--glass-transparency, 0.5) * 0.06));
|
||||
--glass-surface-raised: rgba(255, 255, 255, calc(0.16 - var(--glass-transparency, 0.5) * 0.08));
|
||||
--glass-surface: rgba(255, 255, 255, calc(0.14 - var(--glass-transparency, 0.5) * 0.1));
|
||||
--glass-surface-soft: rgba(255, 255, 255, calc(0.13 - var(--glass-transparency, 0.5) * 0.08));
|
||||
--glass-surface-raised: rgba(255, 255, 255, calc(0.18 - var(--glass-transparency, 0.5) * 0.11));
|
||||
--glass-control: rgba(255, 255, 255, 9%);
|
||||
--glass-control-prominent: rgba(255, 255, 255, 10%);
|
||||
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
|
||||
@@ -195,11 +195,16 @@ html[data-theme='glass'] {
|
||||
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
|
||||
--glass-overlay-surface: var(--glass-surface-raised);
|
||||
--glass-overlay-surface: rgba(
|
||||
var(--v-theme-background),
|
||||
calc(0.76 - var(--glass-transparency, 0.5) * 0.12)
|
||||
);
|
||||
--glass-overlay-blur: var(--glass-blur-raised);
|
||||
--glass-overlay-saturate: var(--glass-saturate);
|
||||
--glass-blur-surface: 28px;
|
||||
--glass-saturate: 150%;
|
||||
--glass-blur-surface: 40px;
|
||||
--glass-blur: 40px;
|
||||
--glass-blur-raised: 60px;
|
||||
--glass-saturate: 180%;
|
||||
--glass-control-icon-color: rgba(242, 245, 250, 78%);
|
||||
--glass-control-placeholder-color: rgba(242, 245, 250, 62%);
|
||||
--glass-control-shortcut-background: rgba(255, 255, 255, 11%);
|
||||
@@ -210,6 +215,10 @@ html[data-theme='glass'] {
|
||||
--glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate))
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
|
||||
.v-overlay__scrim {
|
||||
background: rgba(3, 7, 18, 62%);
|
||||
}
|
||||
|
||||
// 磨砂弹窗只由最外层表面采样背景,内部卡片与自定义分组表面保持扁平。
|
||||
.v-overlay__content > :where(.v-card, .v-sheet),
|
||||
.v-overlay__content > form > :where(.v-card, .v-sheet) {
|
||||
@@ -302,38 +311,23 @@ html[data-theme='glass'] {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// 页面路由只使用短距离透明度与位移反馈,避免触发整页滤镜重采样。
|
||||
// 页面 DOM 与 scroll renderer 共用同一 motion 时钟;canvas 本身保持壁纸坐标不动。
|
||||
.mp-page-route--entering {
|
||||
animation: glass-page-route-enter var(--mp-motion-duration-page) var(--mp-motion-ease-standard) both;
|
||||
animation: none;
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
&[data-page-presentation-motion='active'] .mp-page-route {
|
||||
filter: none;
|
||||
opacity: var(--mp-page-motion-opacity, 1);
|
||||
transform: translate3d(0, var(--mp-page-motion-translate-y, 0), 0);
|
||||
transform-origin: center top;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
@keyframes glass-page-route-enter {
|
||||
from {
|
||||
opacity: 0.82;
|
||||
transform: translate3d(0, 2px, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mp-page-enter-active,
|
||||
.mp-page-leave-active {
|
||||
filter: none !important;
|
||||
transition:
|
||||
opacity var(--mp-motion-duration-page) var(--mp-motion-ease-standard),
|
||||
transform var(--mp-motion-duration-page) var(--mp-motion-ease-standard);
|
||||
}
|
||||
|
||||
.mp-page-enter-from {
|
||||
filter: none !important;
|
||||
opacity: 0.82;
|
||||
transform: translate3d(0, 2px, 0);
|
||||
}
|
||||
|
||||
:where(
|
||||
.app-surface:not(.no-blur),
|
||||
.v-card:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
|
||||
@@ -827,13 +821,6 @@ html[data-theme='glass'] {
|
||||
--mobile-calendar-surface-blur: var(--glass-surface-backdrop-filter);
|
||||
}
|
||||
|
||||
// 磨砂卡片不参与仪表板整体透明度入场,避免合成层切换造成先透明后模糊的闪变。
|
||||
&[data-glass-appearance='frosted'] .dashboard-grid {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
// 用户权限编辑器由普通容器构成,通过主题变量接入玻璃表面材质。
|
||||
.user-permission-editor {
|
||||
--permission-editor-border: 1px solid var(--glass-border);
|
||||
@@ -1040,8 +1027,8 @@ html[data-theme='glass'][data-glass-appearance='frosted']:is(
|
||||
[data-glass-quality='high']
|
||||
)[data-glass-renderer-state='ready'] {
|
||||
--glass-navbar-backdrop-filter: none;
|
||||
--glass-overlay-blur: 8px;
|
||||
--glass-overlay-saturate: 145%;
|
||||
--glass-overlay-blur: 0px;
|
||||
--glass-overlay-saturate: 100%;
|
||||
}
|
||||
|
||||
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('glass optics geometry', () => {
|
||||
expect(getGlassOpticalReflectionStrengthScale(0)).toBe(0)
|
||||
expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1)
|
||||
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
|
||||
expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28)
|
||||
expect(getGlassOpticalTransparency(0)).toBe(0)
|
||||
expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6)
|
||||
expect(getGlassOpticalTransparency(70)).toBeCloseTo(0.96)
|
||||
expect(getGlassOpticalTransparency(100)).toBeCloseTo(1.1)
|
||||
@@ -76,9 +76,9 @@ describe('glass optics geometry', () => {
|
||||
expect(getGlassOpticalTransmissionStrength(0)).toBe(0)
|
||||
expect(getGlassOpticalTransmissionStrength(70)).toBe(1)
|
||||
expect(getGlassOpticalTransmissionStrength(100)).toBe(1.3)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.82)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1.28)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.48)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.84)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.08)
|
||||
})
|
||||
|
||||
it('keeps presets as concrete six-parameter values', () => {
|
||||
|
||||
35
src/utils/__tests__/glassWallpaperTone.spec.ts
Normal file
35
src/utils/__tests__/glassWallpaperTone.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, getGlassWallpaperToneProfile } from '@/utils/glassWallpaperTone'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('glass wallpaper tone profile', () => {
|
||||
it('keeps a representative mid-tone wallpaper near neutral exposure', () => {
|
||||
const profile = getGlassWallpaperToneProfile([0.18, 0.3, 0.36, 0.4, 0.52, 0.72, 0.82])
|
||||
|
||||
expect(profile.medianLuminance).toBeCloseTo(0.4)
|
||||
expect(profile.highlightLuminance).toBeCloseTo(0.72)
|
||||
expect(profile.exposure).toBeGreaterThan(0.98)
|
||||
expect(profile.exposure).toBeLessThan(1.04)
|
||||
})
|
||||
|
||||
it('uses bounded compensation without erasing bright and dark wallpaper character', () => {
|
||||
const dark = getGlassWallpaperToneProfile([0.01, 0.03, 0.06, 0.1, 0.14, 0.22, 0.32])
|
||||
const bright = getGlassWallpaperToneProfile([0.42, 0.58, 0.7, 0.78, 0.86, 0.94, 1])
|
||||
|
||||
expect(dark.exposure).toBe(1.14)
|
||||
expect(bright.exposure).toBeGreaterThanOrEqual(0.88)
|
||||
expect(bright.exposure).toBeLessThan(0.92)
|
||||
expect(dark.exposure).toBeGreaterThan(bright.exposure)
|
||||
})
|
||||
|
||||
it('uses the highlight percentile to lower exposure for locally overbright wallpapers', () => {
|
||||
const controlled = getGlassWallpaperToneProfile([0.2, 0.28, 0.34, 0.38, 0.42, 0.5, 0.58])
|
||||
const highlighted = getGlassWallpaperToneProfile([0.2, 0.28, 0.34, 0.38, 0.42, 0.95, 1])
|
||||
|
||||
expect(highlighted.medianLuminance).toBe(controlled.medianLuminance)
|
||||
expect(highlighted.exposure).toBeLessThan(controlled.exposure)
|
||||
})
|
||||
|
||||
it('falls back to the neutral profile when no valid samples exist', () => {
|
||||
expect(getGlassWallpaperToneProfile([Number.NaN])).toEqual(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
||||
})
|
||||
})
|
||||
@@ -290,7 +290,7 @@ export function getGlassOpticalTransparency(value: unknown) {
|
||||
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
|
||||
const progress = normalized / GLASS_OPTICAL_REFERENCE_STRENGTH
|
||||
|
||||
return 0.28 + 0.68 * progress ** 1.25
|
||||
return 0.96 * progress ** 0.83
|
||||
}
|
||||
|
||||
const highRangeProgress =
|
||||
@@ -299,19 +299,19 @@ export function getGlassOpticalTransparency(value: unknown) {
|
||||
return 0.96 + 0.14 * highRangeProgress ** 1.35
|
||||
}
|
||||
|
||||
/** 标准 CSS 材质使用受控亮度曲线,并在高区间保留有限余量。 */
|
||||
/** 标准 CSS 材质在壁纸归一化后只做窄幅目标亮度调整,避免重新放大原始明暗差异。 */
|
||||
export function getGlassOpticalCssTransmissionBrightness(value: unknown) {
|
||||
const transmission = getGlassOpticalTransmissionStrength(value)
|
||||
if (transmission <= 1) {
|
||||
return 0.82 + 0.46 * transmission ** 1.1
|
||||
return 0.84 + 0.16 * transmission ** 1.05
|
||||
}
|
||||
|
||||
const progress = (transmission - 1) / 0.3
|
||||
|
||||
return 1.28 + 0.2 * progress ** 1.2
|
||||
return 1 + 0.08 * progress ** 1.1
|
||||
}
|
||||
|
||||
/** 实时 renderer 接收透射响应,并在 shader 中按材质和质量执行高亮保护。 */
|
||||
/** 实时 renderer 以 70 为归一化目标亮度参考,并在 shader 中按材质和质量执行高亮保护。 */
|
||||
export function getGlassOpticalTransmissionStrength(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
|
||||
|
||||
132
src/utils/glassWallpaperTone.ts
Normal file
132
src/utils/glassWallpaperTone.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
export interface GlassWallpaperToneProfile {
|
||||
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
|
||||
exposure: number
|
||||
/** 壁纸采样的高亮分位亮度,用于约束亮场曝光。 */
|
||||
highlightLuminance: number
|
||||
/** 壁纸采样的中位亮度,用于确定稳健的整体曝光。 */
|
||||
medianLuminance: number
|
||||
}
|
||||
|
||||
const ANALYSIS_MAX_EDGE = 64
|
||||
const PROFILE_CACHE_LIMIT = 32
|
||||
const PROFILE_LOAD_TIMEOUT_MS = 3000
|
||||
const EXPOSURE_MIN = 0.88
|
||||
const EXPOSURE_MAX = 1.14
|
||||
const MEDIAN_TARGET = 0.38
|
||||
const HIGHLIGHT_TARGET = 0.82
|
||||
|
||||
export const DEFAULT_GLASS_WALLPAPER_TONE_PROFILE: GlassWallpaperToneProfile = {
|
||||
exposure: 1,
|
||||
highlightLuminance: HIGHLIGHT_TARGET,
|
||||
medianLuminance: MEDIAN_TARGET,
|
||||
}
|
||||
|
||||
const profileCache = new Map<string, Promise<GlassWallpaperToneProfile>>()
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function readPercentile(sorted: number[], percentile: number) {
|
||||
if (!sorted.length) return 0
|
||||
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * percentile)))]
|
||||
}
|
||||
|
||||
/**
|
||||
* 以中位亮度为主、高亮分位为辅计算有限曝光。
|
||||
* 指数响应只缩小跨壁纸差异,保留亮场与暗场各自的视觉性格。
|
||||
*/
|
||||
export function getGlassWallpaperToneProfile(luminances: number[]): GlassWallpaperToneProfile {
|
||||
const sorted = luminances
|
||||
.filter(Number.isFinite)
|
||||
.map(value => clamp(value, 0, 1))
|
||||
.sort((a, b) => a - b)
|
||||
if (!sorted.length) return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
|
||||
|
||||
const medianLuminance = readPercentile(sorted, 0.5)
|
||||
const highlightLuminance = readPercentile(sorted, 0.9)
|
||||
const medianExposure = (MEDIAN_TARGET / Math.max(medianLuminance, 0.06)) ** 0.22
|
||||
const highlightExposure = (HIGHLIGHT_TARGET / Math.max(highlightLuminance, 0.15)) ** 0.16
|
||||
const exposure = clamp(medianExposure * 0.75 + highlightExposure * 0.25, EXPOSURE_MIN, EXPOSURE_MAX)
|
||||
|
||||
return {
|
||||
exposure,
|
||||
highlightLuminance,
|
||||
medianLuminance,
|
||||
}
|
||||
}
|
||||
|
||||
/** 从已解码图片生成与 renderer、DOM 背景层共用的稳健亮度 profile。 */
|
||||
export function analyzeGlassWallpaperTone(image: CanvasImageSource, width: number, height: number) {
|
||||
if (typeof document === 'undefined' || width <= 0 || height <= 0) {
|
||||
return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
|
||||
}
|
||||
|
||||
try {
|
||||
const scale = Math.min(1, ANALYSIS_MAX_EDGE / Math.max(width, height))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.max(1, Math.round(width * scale))
|
||||
canvas.height = Math.max(1, Math.round(height * scale))
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!context) return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
|
||||
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data
|
||||
const luminances: number[] = []
|
||||
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
if (pixels[offset + 3] < 128) continue
|
||||
luminances.push((pixels[offset] * 0.2126 + pixels[offset + 1] * 0.7152 + pixels[offset + 2] * 0.0722) / 255)
|
||||
}
|
||||
|
||||
return getGlassWallpaperToneProfile(luminances)
|
||||
} catch {
|
||||
return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
|
||||
}
|
||||
}
|
||||
|
||||
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile>) {
|
||||
if (profileCache.size >= PROFILE_CACHE_LIMIT) {
|
||||
const oldestKey = profileCache.keys().next().value
|
||||
if (oldestKey) profileCache.delete(oldestKey)
|
||||
}
|
||||
|
||||
profileCache.set(url, profile)
|
||||
return profile
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 DOM 背景层加载可读像素。跨域来源不支持 CORS 时回落中性 profile,
|
||||
* 避免亮度分析阻断壁纸本身的 CSS 显示能力。
|
||||
*/
|
||||
export function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
|
||||
if (!url || typeof Image === 'undefined') return Promise.resolve({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
|
||||
|
||||
const cached = profileCache.get(url)
|
||||
if (cached) return cached
|
||||
|
||||
const profile = new Promise<GlassWallpaperToneProfile>(resolve => {
|
||||
const image = new Image()
|
||||
let settled = false
|
||||
const finish = (result: GlassWallpaperToneProfile) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timeout)
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(
|
||||
() => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }),
|
||||
PROFILE_LOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
image.crossOrigin = 'anonymous'
|
||||
image.decoding = 'async'
|
||||
image.onload = () =>
|
||||
finish(analyzeGlassWallpaperTone(image, image.naturalWidth || image.width, image.naturalHeight || image.height))
|
||||
image.onerror = () => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
|
||||
image.src = url
|
||||
})
|
||||
|
||||
return rememberProfile(url, profile)
|
||||
}
|
||||
@@ -1716,190 +1716,186 @@ function onDragStartPlugin(evt: any) {
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition px-2" :touch="false">
|
||||
<!-- 我的插件 -->
|
||||
<VWindowItem value="installed">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 py-0 app-surface-static">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
<VBtn variant="tonal" color="error" @click="sortMode = false">
|
||||
{{ t('common.exit') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<!-- 文件夹和插件网格 -->
|
||||
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
|
||||
<!-- 混合排序列表(文件夹和插件) -->
|
||||
<template v-if="!currentFolder">
|
||||
<!-- 主列表:使用draggable进行混合排序 -->
|
||||
<Draggable
|
||||
v-if="canDragSort"
|
||||
v-model="mixedSortList"
|
||||
@end="saveMixedSortOrder"
|
||||
@start="onDragStartPlugin"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="grid gap-4 grid-plugin-card"
|
||||
group="mixed"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<PluginMixedSortCard
|
||||
:item="element"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="true"
|
||||
@open-folder="openFolder"
|
||||
@delete-folder="deleteFolder"
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledMainList"
|
||||
:items="mixedSortList"
|
||||
:get-item-key="item => `${item.type}:${item.id}`"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="180"
|
||||
:scroll-to-index="installedScrollToIndex"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginMixedSortCard
|
||||
:item="item"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="false"
|
||||
@open-folder="openFolder"
|
||||
@delete-folder="deleteFolder"
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- 文件夹内:使用draggable排序 + 移出按钮 -->
|
||||
<Draggable
|
||||
v-if="canDragSort"
|
||||
v-model="draggableFolderPlugins"
|
||||
@end="saveFolderPluginOrder"
|
||||
@start="onDragStartPlugin"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="grid gap-4 grid-plugin-card"
|
||||
group="plugins"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<PluginMixedSortCard
|
||||
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="true"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@remove-from-folder="removeFromFolder"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledFolderList"
|
||||
:items="draggableFolderPlugins"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="180"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginMixedSortCard
|
||||
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="false"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@remove-from-folder="removeFromFolder"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</template>
|
||||
<div>
|
||||
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 py-0 app-surface-static">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
<VBtn variant="tonal" color="error" @click="sortMode = false">
|
||||
{{ t('common.exit') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<NoDataFound
|
||||
v-if="displayedFolders.length === 0 && displayedPlugins.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
:error-title="t('common.noData')"
|
||||
:error-description="
|
||||
installedFilter || hasUpdateFilter ? t('plugin.noMatchingContent') : t('plugin.pleaseInstallFromMarket')
|
||||
"
|
||||
/>
|
||||
<!-- 文件夹和插件网格 -->
|
||||
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
|
||||
<!-- 混合排序列表(文件夹和插件) -->
|
||||
<template v-if="!currentFolder">
|
||||
<!-- 主列表:使用draggable进行混合排序 -->
|
||||
<Draggable
|
||||
v-if="canDragSort"
|
||||
v-model="mixedSortList"
|
||||
@end="saveMixedSortOrder"
|
||||
@start="onDragStartPlugin"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="grid gap-4 grid-plugin-card"
|
||||
group="mixed"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<PluginMixedSortCard
|
||||
:item="element"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="true"
|
||||
@open-folder="openFolder"
|
||||
@delete-folder="deleteFolder"
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledMainList"
|
||||
:items="mixedSortList"
|
||||
:get-item-key="item => `${item.type}:${item.id}`"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="180"
|
||||
:scroll-to-index="installedScrollToIndex"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginMixedSortCard
|
||||
:item="item"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="false"
|
||||
@open-folder="openFolder"
|
||||
@delete-folder="deleteFolder"
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- 文件夹内:使用draggable排序 + 移出按钮 -->
|
||||
<Draggable
|
||||
v-if="canDragSort"
|
||||
v-model="draggableFolderPlugins"
|
||||
@end="saveFolderPluginOrder"
|
||||
@start="onDragStartPlugin"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="grid gap-4 grid-plugin-card"
|
||||
group="plugins"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<PluginMixedSortCard
|
||||
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="true"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@remove-from-folder="removeFromFolder"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledFolderList"
|
||||
:items="draggableFolderPlugins"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="180"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginMixedSortCard
|
||||
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="false"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@remove-from-folder="removeFromFolder"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<NoDataFound
|
||||
v-if="displayedFolders.length === 0 && displayedPlugins.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
:error-title="t('common.noData')"
|
||||
:error-description="
|
||||
installedFilter || hasUpdateFilter ? t('plugin.noMatchingContent') : t('plugin.pleaseInstallFromMarket')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<!-- 插件市场 -->
|
||||
<VWindowItem value="market">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<LoadingBanner
|
||||
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||
class="mt-12"
|
||||
/>
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||
mode="intersect"
|
||||
side="end"
|
||||
<div>
|
||||
<LoadingBanner
|
||||
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||
class="mt-12"
|
||||
/>
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="displayUninstalledList"
|
||||
@load="loadMarketMore"
|
||||
class="overflow-visible"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<ProgressiveCardGrid
|
||||
v-if="displayUninstalledList.length > 0"
|
||||
:items="displayUninstalledList"
|
||||
@load="loadMarketMore"
|
||||
class="overflow-visible"
|
||||
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="260"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<ProgressiveCardGrid
|
||||
v-if="displayUninstalledList.length > 0"
|
||||
:items="displayUninstalledList"
|
||||
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="260"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VInfiniteScroll>
|
||||
<NoDataFound
|
||||
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
|
||||
error-code="404"
|
||||
:error-title="t('common.noData')"
|
||||
:error-description="t('plugin.allPluginsInstalled')"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
<template #default="{ item }">
|
||||
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VInfiniteScroll>
|
||||
<NoDataFound
|
||||
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
|
||||
error-code="404"
|
||||
:error-title="t('common.noData')"
|
||||
:error-description="t('plugin.allPluginsInstalled')"
|
||||
/>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user