mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
fix(glass): stabilize optical rendering and page presentation (#595)
This commit is contained in:
+64
-7
@@ -41,7 +41,13 @@ import {
|
|||||||
getLoginVisualProfile,
|
getLoginVisualProfile,
|
||||||
prepareLoginBackgroundLayer,
|
prepareLoginBackgroundLayer,
|
||||||
settleLoginBackgroundLayers,
|
settleLoginBackgroundLayers,
|
||||||
|
type LoginBackgroundLayer,
|
||||||
} from '@/utils/loginPresentation'
|
} from '@/utils/loginPresentation'
|
||||||
|
import {
|
||||||
|
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||||
|
loadGlassWallpaperToneProfile,
|
||||||
|
type GlassWallpaperToneProfile,
|
||||||
|
} from '@/utils/glassWallpaperTone'
|
||||||
|
|
||||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||||
@@ -83,6 +89,7 @@ const globalSettingsStore = useGlobalSettingsStore()
|
|||||||
// 背景图片
|
// 背景图片
|
||||||
const backgroundImages = ref<string[]>([])
|
const backgroundImages = ref<string[]>([])
|
||||||
const backgroundLayers = ref(createLoginBackgroundLayers())
|
const backgroundLayers = ref(createLoginBackgroundLayers())
|
||||||
|
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
|
||||||
const activeImageIndex = ref(0)
|
const activeImageIndex = ref(0)
|
||||||
const previousImageIndex = ref<number | null>(null)
|
const previousImageIndex = ref<number | null>(null)
|
||||||
const isBackgroundCrossfading = ref(false)
|
const isBackgroundCrossfading = ref(false)
|
||||||
@@ -212,6 +219,31 @@ function handleTransparencySettingsChanged(event: Event) {
|
|||||||
transparencyGlassQuality.value = glassQuality
|
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()
|
applyTransparentBackgroundSettings()
|
||||||
|
|
||||||
void router.isReady().then(() => {
|
void router.isReady().then(() => {
|
||||||
@@ -501,13 +533,29 @@ function preloadNextBackgroundImage() {
|
|||||||
|
|
||||||
/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */
|
/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */
|
||||||
async function preloadBackgroundCandidate(imageUrl: string) {
|
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 opticalUrl = getOpticalBackgroundImage(imageUrl)
|
||||||
const opticalReady = await preloadCorsImage(opticalUrl)
|
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,
|
preload: preloadImage,
|
||||||
})
|
})
|
||||||
if (!imagesReady) continue
|
if (!imagesReady) continue
|
||||||
|
await ensureBackgroundToneProfile(nextImage)
|
||||||
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
||||||
|
|
||||||
activateBackgroundImage(nextIndex)
|
activateBackgroundImage(nextIndex)
|
||||||
@@ -811,6 +860,14 @@ onMounted(async () => {
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(isGlassTheme, enabled => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
void Promise.all(
|
||||||
|
renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => ensureBackgroundToneProfile(layer.url)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// 使用优化后的加载界面移除逻辑
|
// 使用优化后的加载界面移除逻辑
|
||||||
ensureRenderComplete(() => {
|
ensureRenderComplete(() => {
|
||||||
nextTick(removeLoadingWithStateCheck)
|
nextTick(removeLoadingWithStateCheck)
|
||||||
@@ -883,7 +940,7 @@ onUnmounted(() => {
|
|||||||
:key="layer.key"
|
:key="layer.key"
|
||||||
class="background-image"
|
class="background-image"
|
||||||
:class="layer.role"
|
:class="layer.role"
|
||||||
:style="{ 'backgroundImage': layer.url ? `url(${layer.url})` : undefined }"
|
:style="getBackgroundLayerStyle(layer)"
|
||||||
/>
|
/>
|
||||||
<!-- 全局磨砂层 -->
|
<!-- 全局磨砂层 -->
|
||||||
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
|
<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.active,
|
||||||
.background-container.is-glass-theme .background-image.previous {
|
.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 {
|
.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.active,
|
||||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous {
|
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 {
|
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.active,
|
||||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous {
|
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 {
|
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
||||||
|
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||||
import {
|
import {
|
||||||
setGlassRendererState,
|
setGlassRendererState,
|
||||||
useGlassOpticalInteractionSource,
|
useGlassOpticalInteractionSource,
|
||||||
@@ -47,6 +48,7 @@ const emit = defineEmits<{
|
|||||||
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const interactionSource = useGlassOpticalInteractionSource()
|
const interactionSource = useGlassOpticalInteractionSource()
|
||||||
|
const pagePresentationMotion = usePagePresentationMotion()
|
||||||
const fixedRenderer = useGlassOpticalRenderer({
|
const fixedRenderer = useGlassOpticalRenderer({
|
||||||
active: true,
|
active: true,
|
||||||
appearance: () => props.appearance,
|
appearance: () => props.appearance,
|
||||||
@@ -76,6 +78,7 @@ const scrollRenderer = useGlassOpticalRenderer({
|
|||||||
deformationStrength: () => props.deformationStrength,
|
deformationStrength: () => props.deformationStrength,
|
||||||
flowStrength: () => props.flowStrength,
|
flowStrength: () => props.flowStrength,
|
||||||
interactionSource,
|
interactionSource,
|
||||||
|
pageMotion: pagePresentationMotion.reader,
|
||||||
quality: () => props.quality,
|
quality: () => props.quality,
|
||||||
reflectionStrength: () => props.reflectionStrength,
|
reflectionStrength: () => props.reflectionStrength,
|
||||||
transparencyStrength: () => props.transparencyStrength,
|
transparencyStrength: () => props.transparencyStrength,
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ describe('GlassOpticalLayer', () => {
|
|||||||
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
||||||
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
||||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).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()
|
wrapper.unmount()
|
||||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ vi.mock('three', async importOriginal => {
|
|||||||
clear() {}
|
clear() {}
|
||||||
dispose() {}
|
dispose() {}
|
||||||
forceContextLoss() {}
|
forceContextLoss() {}
|
||||||
|
getRenderTarget() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
render() {}
|
render() {}
|
||||||
setClearColor() {}
|
setClearColor() {}
|
||||||
setPixelRatio() {}
|
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 () => {
|
it('recovers after consecutive WebGL context loss cycles', async () => {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
const scope = effectScope()
|
const scope = effectScope()
|
||||||
@@ -376,6 +455,36 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
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 () => {
|
it('releases renderer resources before restoring a single active instance', async () => {
|
||||||
const three = await import('three')
|
const three = await import('three')
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -427,7 +536,7 @@ describe('glass optical surface discovery', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(rendererDispose).toHaveBeenCalledTimes(1)
|
expect(rendererDispose).toHaveBeenCalledTimes(1)
|
||||||
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeFirstRelease + 2)
|
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeFirstRelease + 3)
|
||||||
expect(contextLoss).toHaveBeenCalledTimes(1)
|
expect(contextLoss).toHaveBeenCalledTimes(1)
|
||||||
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
|
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
|
||||||
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
|
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
|
||||||
@@ -460,7 +569,7 @@ describe('glass optical surface discovery', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(rendererDispose).toHaveBeenCalledTimes(2)
|
expect(rendererDispose).toHaveBeenCalledTimes(2)
|
||||||
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeSecondRelease + 2)
|
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeSecondRelease + 3)
|
||||||
expect(contextLoss).toHaveBeenCalledTimes(2)
|
expect(contextLoss).toHaveBeenCalledTimes(2)
|
||||||
expect(resizeDisconnect).toHaveBeenCalledTimes(2)
|
expect(resizeDisconnect).toHaveBeenCalledTimes(2)
|
||||||
expect(mutationDisconnect).toHaveBeenCalledTimes(2)
|
expect(mutationDisconnect).toHaveBeenCalledTimes(2)
|
||||||
@@ -493,19 +602,17 @@ describe('glass optical surface discovery', () => {
|
|||||||
|
|
||||||
quality.value = 'balanced'
|
quality.value = 'balanced'
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
await vi.waitFor(() => expect(renderTargetDispose).toHaveBeenCalledTimes(4))
|
||||||
|
|
||||||
expect(rendererDispose).not.toHaveBeenCalled()
|
expect(rendererDispose).not.toHaveBeenCalled()
|
||||||
expect(contextLoss).not.toHaveBeenCalled()
|
expect(contextLoss).not.toHaveBeenCalled()
|
||||||
expect(renderTargetDispose).toHaveBeenCalledTimes(2)
|
|
||||||
|
|
||||||
quality.value = 'high'
|
quality.value = 'high'
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
await vi.waitFor(() => expect(renderTargetDispose).toHaveBeenCalledTimes(6))
|
||||||
|
|
||||||
expect(rendererDispose).not.toHaveBeenCalled()
|
expect(rendererDispose).not.toHaveBeenCalled()
|
||||||
expect(contextLoss).not.toHaveBeenCalled()
|
expect(contextLoss).not.toHaveBeenCalled()
|
||||||
expect(renderTargetDispose).toHaveBeenCalledTimes(4)
|
|
||||||
|
|
||||||
scope.stop()
|
scope.stop()
|
||||||
})
|
})
|
||||||
@@ -676,6 +783,9 @@ describe('glass optical surface discovery', () => {
|
|||||||
material: {
|
material: {
|
||||||
fragmentShader: string
|
fragmentShader: string
|
||||||
uniforms: {
|
uniforms: {
|
||||||
|
uFrostedTexture: { value: unknown }
|
||||||
|
uHasFrostedTexture: { value: number }
|
||||||
|
uPreviousFrostedTexture: { value: unknown }
|
||||||
uPreviousTexture: { value: unknown }
|
uPreviousTexture: { value: unknown }
|
||||||
uTexture: { value: unknown }
|
uTexture: { value: unknown }
|
||||||
uTextureMix: { value: number }
|
uTextureMix: { value: number }
|
||||||
@@ -685,11 +795,17 @@ describe('glass optical surface discovery', () => {
|
|||||||
}
|
}
|
||||||
const uniforms = scene.children[0].material.uniforms
|
const uniforms = scene.children[0].material.uniforms
|
||||||
expect(uniforms.uPreviousTexture.value).not.toBe(uniforms.uTexture.value)
|
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).toBeGreaterThanOrEqual(0)
|
||||||
expect(uniforms.uTextureMix.value).toBeLessThan(1)
|
expect(uniforms.uTextureMix.value).toBeLessThan(1)
|
||||||
expect(scene.children[0].material.fragmentShader).toContain(
|
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(
|
expect(scene.children[0].material.fragmentShader).not.toContain(
|
||||||
'toneMapWallpaper(mix(previous, current, uTextureMix), viewportUv)',
|
'toneMapWallpaper(mix(previous, current, uTextureMix), viewportUv)',
|
||||||
)
|
)
|
||||||
@@ -734,7 +850,72 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
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')
|
const three = await import('three')
|
||||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
|
||||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
|
||||||
@@ -771,12 +952,51 @@ describe('glass optical surface discovery', () => {
|
|||||||
|
|
||||||
presentationHeight = 5000
|
presentationHeight = 5000
|
||||||
observer?.trigger()
|
observer?.trigger()
|
||||||
|
vi.advanceTimersByTime(159)
|
||||||
|
expect(setSize).not.toHaveBeenCalled()
|
||||||
|
vi.advanceTimersByTime(1)
|
||||||
|
|
||||||
|
expect(setSize).toHaveBeenCalledOnce()
|
||||||
expect(setSize).toHaveBeenCalledWith(390, 3072, false)
|
expect(setSize).toHaveBeenCalledWith(390, 3072, false)
|
||||||
expect(render).toHaveBeenCalled()
|
expect(render).toHaveBeenCalled()
|
||||||
scope.stop()
|
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 () => {
|
it('writes static-material surfaces into the shader without dynamic optical energy', async () => {
|
||||||
const three = await import('three')
|
const three = await import('three')
|
||||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 320, x: 40, y: 80 })
|
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 uReflectionStrength')
|
||||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransparency')
|
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 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 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('vec3 transmissionReference')
|
||||||
expect(scene.children[0].material.fragmentShader).toContain('float shadowLiftGate')
|
expect(scene.children[0].material.fragmentShader).toContain('float referenceLift')
|
||||||
expect(scene.children[0].material.fragmentShader).toContain('float shadowColorRetention')
|
|
||||||
expect(scene.children[0].material.fragmentShader).toContain('float highlightProtection')
|
expect(scene.children[0].material.fragmentShader).toContain('float highlightProtection')
|
||||||
expect(scene.children[0].material.fragmentShader).toContain('protectedHighlightReference')
|
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('uMotion *')
|
||||||
expect(scene.children[0].material.fragmentShader).toContain('uTranslationStrength')
|
expect(scene.children[0].material.fragmentShader).toContain('uTranslationStrength')
|
||||||
expect(scene.children[0].material.fragmentShader).toContain('vec2 lightDirection = normalize(vec2(-0.68, 0.74))')
|
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(
|
expect(scene.children[0].material.fragmentShader).toContain(
|
||||||
'causticHighlightMix * uReflectionStrength * highlightBudget',
|
'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('mix(0.035, 0.4')
|
||||||
expect(scene.children[0].material.fragmentShader).not.toContain('sin(')
|
expect(scene.children[0].material.fragmentShader).not.toContain('sin(')
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
type GlassOpticalSurfaceSlot,
|
||||||
} from '@/utils/glassOptics'
|
} from '@/utils/glassOptics'
|
||||||
import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer'
|
import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer'
|
||||||
|
import type { PagePresentationMotionReader } from '@/composables/usePagePresentationMotion'
|
||||||
import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle'
|
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')
|
type ThreeModule = typeof import('three')
|
||||||
export type GlassRendererState = 'fallback' | 'loading' | 'ready'
|
export type GlassRendererState = 'fallback' | 'loading' | 'ready'
|
||||||
@@ -154,6 +160,7 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
|
|||||||
uFlowStrength: IUniform<number>
|
uFlowStrength: IUniform<number>
|
||||||
uHasWallpaperTexture: IUniform<number>
|
uHasWallpaperTexture: IUniform<number>
|
||||||
uHasFlowTexture: IUniform<number>
|
uHasFlowTexture: IUniform<number>
|
||||||
|
uHasFrostedTexture: IUniform<number>
|
||||||
uMotion: IUniform<number>
|
uMotion: IUniform<number>
|
||||||
uMotionExpansion: IUniform<number>
|
uMotionExpansion: IUniform<number>
|
||||||
uMaxRefractionPixels: IUniform<number>
|
uMaxRefractionPixels: IUniform<number>
|
||||||
@@ -161,6 +168,7 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
|
|||||||
uPointerVelocity: IUniform<Vector2>
|
uPointerVelocity: IUniform<Vector2>
|
||||||
uPresentationSize: IUniform<Vector2>
|
uPresentationSize: IUniform<Vector2>
|
||||||
uPreviousCoverScale: IUniform<Vector2>
|
uPreviousCoverScale: IUniform<Vector2>
|
||||||
|
uPreviousWallpaperExposure: IUniform<number>
|
||||||
uQuality: IUniform<number>
|
uQuality: IUniform<number>
|
||||||
uReflectionStrength: IUniform<number>
|
uReflectionStrength: IUniform<number>
|
||||||
uRadii: IUniform<Vector4[]>
|
uRadii: IUniform<Vector4[]>
|
||||||
@@ -169,8 +177,11 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
|
|||||||
uSurfaceWeights: IUniform<number[]>
|
uSurfaceWeights: IUniform<number[]>
|
||||||
uSurfaceDynamics: IUniform<number[]>
|
uSurfaceDynamics: IUniform<number[]>
|
||||||
uPreviousTexture: IUniform<Texture | null>
|
uPreviousTexture: IUniform<Texture | null>
|
||||||
|
uPreviousFrostedTexture: IUniform<Texture | null>
|
||||||
uTexture: IUniform<Texture | null>
|
uTexture: IUniform<Texture | null>
|
||||||
|
uFrostedTexture: IUniform<Texture | null>
|
||||||
uTextureMix: IUniform<number>
|
uTextureMix: IUniform<number>
|
||||||
|
uWallpaperExposure: IUniform<number>
|
||||||
uTintColor: IUniform<Color>
|
uTintColor: IUniform<Color>
|
||||||
uTransparency: IUniform<number>
|
uTransparency: IUniform<number>
|
||||||
uTransmissionStrength: IUniform<number>
|
uTransmissionStrength: IUniform<number>
|
||||||
@@ -212,6 +223,17 @@ interface GlassFlowResources {
|
|||||||
writeTarget: WebGLRenderTarget
|
writeTarget: WebGLRenderTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GlassFrostPrefilterResources {
|
||||||
|
material: ShaderMaterial
|
||||||
|
mesh: Mesh
|
||||||
|
scene: Scene
|
||||||
|
uniforms: {
|
||||||
|
uDirection: IUniform<Vector2>
|
||||||
|
uTexture: IUniform<Texture | null>
|
||||||
|
uTextureSize: IUniform<Vector2>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface UseGlassOpticalRendererOptions {
|
interface UseGlassOpticalRendererOptions {
|
||||||
active: MaybeRefOrGetter<boolean>
|
active: MaybeRefOrGetter<boolean>
|
||||||
appearance: MaybeRefOrGetter<ThemeCustomizerGlassAppearance>
|
appearance: MaybeRefOrGetter<ThemeCustomizerGlassAppearance>
|
||||||
@@ -221,6 +243,8 @@ interface UseGlassOpticalRendererOptions {
|
|||||||
interactionSource?: GlassOpticalInteractionSource
|
interactionSource?: GlassOpticalInteractionSource
|
||||||
/** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */
|
/** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */
|
||||||
motionStrength?: MaybeRefOrGetter<number>
|
motionStrength?: MaybeRefOrGetter<number>
|
||||||
|
/** scroll-space 页面表面与 DOM 共用的短时呈现状态;fixed-space 不参与路由入场。 */
|
||||||
|
pageMotion?: PagePresentationMotionReader
|
||||||
quality: MaybeRefOrGetter<GlassOpticalQuality>
|
quality: MaybeRefOrGetter<GlassOpticalQuality>
|
||||||
reflectionStrength?: MaybeRefOrGetter<number>
|
reflectionStrength?: MaybeRefOrGetter<number>
|
||||||
previousWallpaperUrl?: MaybeRefOrGetter<string>
|
previousWallpaperUrl?: MaybeRefOrGetter<string>
|
||||||
@@ -244,12 +268,16 @@ type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement> &
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PreparedWallpaperTexture {
|
interface PreparedWallpaperTexture {
|
||||||
|
/** 单次预滤后的低分辨率壁纸;中间 RenderTarget 不跨准备阶段保留。 */
|
||||||
|
frostedTarget: WebGLRenderTarget | null
|
||||||
/** 是否包含可采样的真实壁纸,而非程序化回退纹理。 */
|
/** 是否包含可采样的真实壁纸,而非程序化回退纹理。 */
|
||||||
hasWallpaperTexture: boolean
|
hasWallpaperTexture: boolean
|
||||||
/** 纹理像素高度。 */
|
/** 纹理像素高度。 */
|
||||||
height: number
|
height: number
|
||||||
/** 已完成当前 WebGL context 上传的纹理。 */
|
/** 已完成当前 WebGL context 上传的纹理。 */
|
||||||
texture: Texture
|
texture: Texture
|
||||||
|
/** 纹理自身的稳健整体曝光;双纹理过渡期间不可与另一张壁纸共用。 */
|
||||||
|
toneProfile: GlassWallpaperToneProfile
|
||||||
/** 纹理像素宽度。 */
|
/** 纹理像素宽度。 */
|
||||||
width: number
|
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 = `
|
const FRAGMENT_SHADER = `
|
||||||
precision highp float;
|
precision highp float;
|
||||||
|
|
||||||
uniform sampler2D uPreviousTexture;
|
uniform sampler2D uPreviousTexture;
|
||||||
uniform sampler2D uTexture;
|
uniform sampler2D uTexture;
|
||||||
|
uniform sampler2D uPreviousFrostedTexture;
|
||||||
|
uniform sampler2D uFrostedTexture;
|
||||||
uniform sampler2D uFlowTexture;
|
uniform sampler2D uFlowTexture;
|
||||||
uniform vec2 uCoverScale;
|
uniform vec2 uCoverScale;
|
||||||
uniform vec2 uPreviousCoverScale;
|
uniform vec2 uPreviousCoverScale;
|
||||||
@@ -359,6 +409,7 @@ uniform float uDeformationStrength;
|
|||||||
uniform float uFlowStrength;
|
uniform float uFlowStrength;
|
||||||
uniform float uHasWallpaperTexture;
|
uniform float uHasWallpaperTexture;
|
||||||
uniform float uHasFlowTexture;
|
uniform float uHasFlowTexture;
|
||||||
|
uniform float uHasFrostedTexture;
|
||||||
uniform float uMotion;
|
uniform float uMotion;
|
||||||
uniform float uMotionExpansion;
|
uniform float uMotionExpansion;
|
||||||
uniform float uMaxRefractionPixels;
|
uniform float uMaxRefractionPixels;
|
||||||
@@ -379,6 +430,8 @@ uniform float uTransparency;
|
|||||||
uniform float uTransmissionStrength;
|
uniform float uTransmissionStrength;
|
||||||
uniform float uTranslationStrength;
|
uniform float uTranslationStrength;
|
||||||
uniform float uTextureMix;
|
uniform float uTextureMix;
|
||||||
|
uniform float uPreviousWallpaperExposure;
|
||||||
|
uniform float uWallpaperExposure;
|
||||||
uniform vec4 uTrail[4];
|
uniform vec4 uTrail[4];
|
||||||
uniform int uTrailCount;
|
uniform int uTrailCount;
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
@@ -409,16 +462,30 @@ vec2 coverUv(vec2 uv) {
|
|||||||
return vec2(0.5) + (viewportUv - vec2(0.5)) * uCoverScale;
|
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 tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
|
||||||
float frosted = step(1.5, uAppearance);
|
float frosted = step(1.5, uAppearance);
|
||||||
float exposure = mix(0.86, 0.85, tinted);
|
float exposure = mix(0.86, 0.85, tinted);
|
||||||
exposure = mix(exposure, 0.82, frosted);
|
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);
|
saturation = mix(saturation, 0.9, frosted);
|
||||||
float contrast = mix(1.02, 1.0, frosted);
|
float contrast = mix(1.02, 1.0, frosted);
|
||||||
float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
vec3 normalized = compressWallpaperLuminance(color, wallpaperExposure);
|
||||||
vec3 mapped = mix(vec3(luminance), color, saturation);
|
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;
|
mapped = clamp((mapped - vec3(0.5)) * contrast + vec3(0.5), 0.0, 1.0) * exposure;
|
||||||
|
|
||||||
float top = 1.0 - uv.y;
|
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);
|
float radialAbsorption = smoothstep(0.24, 0.92, length(radialDelta)) * mix(0.12, 0.14, tinted);
|
||||||
radialAbsorption *= 1.0 - frosted;
|
radialAbsorption *= 1.0 - frosted;
|
||||||
vec3 absorbed = mapped * (1.0 - linearAbsorption) * (1.0 - radialAbsorption);
|
vec3 absorbed = mapped * (1.0 - linearAbsorption) * (1.0 - radialAbsorption);
|
||||||
float transmissionExpansion = max(0.0, (uTransmissionStrength - 0.5) * 2.0);
|
float transmissionResponse =
|
||||||
float transmissionResponse = pow(transmissionExpansion, mix(0.8, 0.65, uQuality));
|
pow(clamp(uTransmissionStrength, 0.0, 1.0), mix(0.9, 0.78, uQuality));
|
||||||
float transmissionMaterialScale = mix(1.0, 0.78, tinted);
|
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);
|
transmissionMaterialScale = mix(transmissionMaterialScale, 0.42, frosted);
|
||||||
float shadowGamma =
|
float highlightProtection = smoothstep(0.68, 0.92, luminance);
|
||||||
1.0 -
|
vec3 transmissionReference = normalized;
|
||||||
transmissionResponse *
|
float referenceLift =
|
||||||
transmissionMaterialScale *
|
transmissionMaterialScale *
|
||||||
mix(0.28, 0.5, uQuality);
|
(
|
||||||
float shadowLiftGate = smoothstep(0.035, 0.2, luminance);
|
referenceLiftProgress * mix(0.18, 0.25, uQuality) +
|
||||||
float highlightProtection = smoothstep(0.72, 0.96, luminance);
|
highTransmissionProgress * mix(0.1, 0.16, uQuality)
|
||||||
float protectedGamma = mix(max(shadowGamma, 0.72), shadowGamma, shadowLiftGate);
|
);
|
||||||
protectedGamma = mix(protectedGamma, 1.0, highlightProtection);
|
transmissionReference *= 1.0 + referenceLift * (1.0 - highlightProtection * 0.72);
|
||||||
vec3 expandedSource = pow(max(color, vec3(0.0)), vec3(protectedGamma));
|
transmissionReference = min(transmissionReference, vec3(0.96));
|
||||||
float sourceLuminance = dot(expandedSource, vec3(0.2126, 0.7152, 0.0722));
|
vec3 protectedHighlightReference = min(normalized, vec3(0.96));
|
||||||
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));
|
|
||||||
transmissionReference = mix(
|
transmissionReference = mix(
|
||||||
transmissionReference,
|
transmissionReference,
|
||||||
protectedHighlightReference,
|
protectedHighlightReference,
|
||||||
highlightProtection * 0.72
|
highlightProtection * 0.78
|
||||||
);
|
);
|
||||||
float transmissionMix = min(
|
float transmissionMix = min(
|
||||||
transmissionResponse *
|
transmissionResponse *
|
||||||
transmissionMaterialScale *
|
transmissionMaterialScale *
|
||||||
mix(0.58, 0.92, uQuality),
|
mix(0.58, 0.84, uQuality),
|
||||||
0.94
|
0.84
|
||||||
);
|
);
|
||||||
|
|
||||||
return mix(absorbed, min(transmissionReference, vec3(1.0)), transmissionMix);
|
return mix(absorbed, transmissionReference, transmissionMix);
|
||||||
}
|
}
|
||||||
|
|
||||||
vec3 sampleWallpaper(vec2 uv) {
|
vec3 sampleWallpaper(vec2 uv) {
|
||||||
vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001));
|
vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001));
|
||||||
vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale;
|
vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale;
|
||||||
vec3 previous = texture2D(uPreviousTexture, previousUv).rgb;
|
vec3 previous;
|
||||||
vec3 current = texture2D(uTexture, uv).rgb;
|
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.001) {
|
||||||
if (uTextureMix >= 0.999) return toneMapWallpaper(current, viewportUv);
|
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) {
|
vec3 sampleChromatic(vec2 uv, float separation) {
|
||||||
@@ -520,7 +596,7 @@ vec3 sampleHighQualityDiffuse(vec2 uv, vec2 axis, float radius) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
float getContentProtection(vec2 sourceUv) {
|
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));
|
vec2 sourceTexel = max(uCoverScale, vec2(0.0001)) / max(uVisibleViewportSize, vec2(1.0));
|
||||||
vec3 horizontalStart = sampleWallpaper(sourceUv - vec2(sourceTexel.x * 2.5, 0.0));
|
vec3 horizontalStart = sampleWallpaper(sourceUv - vec2(sourceTexel.x * 2.5, 0.0));
|
||||||
@@ -661,7 +737,7 @@ void main() {
|
|||||||
pointerEnergy,
|
pointerEnergy,
|
||||||
max(min(1.0, trailEnergy) * 0.68, max(temporalEnergy * 0.76, wakeEnergy * 0.82))
|
max(min(1.0, trailEnergy) * 0.68, max(temporalEnergy * 0.76, wakeEnergy * 0.82))
|
||||||
), 0.0, 1.0);
|
), 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 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 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);
|
float temporalStrength = mix(0.032, 0.042, frosted) * uQuality * (1.0 + flowSurfaceDetail * 0.5);
|
||||||
@@ -707,11 +783,16 @@ void main() {
|
|||||||
dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction);
|
dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction);
|
||||||
vec2 refraction = staticRefraction + dynamicRefraction;
|
vec2 refraction = staticRefraction + dynamicRefraction;
|
||||||
vec2 sourceUv = coverUv(vUv + refraction);
|
vec2 sourceUv = coverUv(vUv + refraction);
|
||||||
float separation = edge * mix(0.00072, 0.0013, uQuality) * mix(1.0, 0.58, frosted);
|
float separation = edge * mix(0.00024, 0.00055, uQuality) * mix(1.0, 0.58, frosted);
|
||||||
vec3 refracted = sampleChromatic(sourceUv, separation);
|
float usesPrefilteredFrost = frosted * uHasFrostedTexture;
|
||||||
|
vec3 refracted = usesPrefilteredFrost > 0.5
|
||||||
|
? sampleWallpaper(sourceUv)
|
||||||
|
: sampleChromatic(sourceUv, separation);
|
||||||
float detailSeparation = separation * mix(1.45, 2.35, uQuality);
|
float detailSeparation = separation * mix(1.45, 2.35, uQuality);
|
||||||
vec3 detailed = sampleChromatic(sourceUv, detailSeparation);
|
vec3 detailed = usesPrefilteredFrost > 0.5
|
||||||
refracted = mix(refracted, detailed, mix(0.12, 0.32, uQuality) * (1.0 - frosted));
|
? 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;
|
vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular;
|
||||||
float diffusionRadius =
|
float diffusionRadius =
|
||||||
mix(0.0022, 0.0038, uQuality) *
|
mix(0.0022, 0.0038, uQuality) *
|
||||||
@@ -720,8 +801,12 @@ void main() {
|
|||||||
materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
|
materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
|
||||||
flowSurfaceDetail * dynamicMask * 0.38
|
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;
|
vec3 diffused;
|
||||||
if (uQuality > 0.5) {
|
if (usesPrefilteredFrost > 0.5) {
|
||||||
|
diffused = refracted;
|
||||||
|
} else if (uQuality > 0.5) {
|
||||||
diffused = sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
diffused = sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||||
} else {
|
} else {
|
||||||
diffused = sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
diffused = sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||||
@@ -729,9 +814,9 @@ void main() {
|
|||||||
refracted = mix(refracted, diffused, frosted);
|
refracted = mix(refracted, diffused, frosted);
|
||||||
float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
|
float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
|
||||||
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
|
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) {
|
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;
|
refracted *= 1.0 + transmissionOffset * dimming;
|
||||||
}
|
}
|
||||||
refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
|
refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
|
||||||
@@ -748,7 +833,9 @@ void main() {
|
|||||||
float edgeHighlightMix = 0.12;
|
float edgeHighlightMix = 0.12;
|
||||||
float causticHighlightMix = 0.075;
|
float causticHighlightMix = 0.075;
|
||||||
float liquidPresence = clamp(materialEnergy, 0.0, 1.0);
|
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 proceduralEdgeAlpha = 0.14;
|
||||||
float proceduralCausticAlpha = 0.075;
|
float proceduralCausticAlpha = 0.075;
|
||||||
|
|
||||||
@@ -756,14 +843,15 @@ void main() {
|
|||||||
highlight = vec3(0.94, 0.97, 1.0);
|
highlight = vec3(0.94, 0.97, 1.0);
|
||||||
edgeHighlightMix = 0.15;
|
edgeHighlightMix = 0.15;
|
||||||
causticHighlightMix = 0.042;
|
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;
|
proceduralEdgeAlpha = 0.16;
|
||||||
proceduralCausticAlpha = 0.045;
|
proceduralCausticAlpha = 0.045;
|
||||||
} else if (uAppearance > 0.5) {
|
} else if (uAppearance > 0.5) {
|
||||||
highlight = mix(vec3(1.0), uTintColor, 0.72);
|
highlight = mix(vec3(1.0), uTintColor, 0.72);
|
||||||
edgeHighlightMix = 0.17;
|
edgeHighlightMix = 0.17;
|
||||||
causticHighlightMix = 0.085;
|
causticHighlightMix = 0.085;
|
||||||
materialAlpha = uTransparency * mix(0.84, 1.0, liquidPresence);
|
materialAlpha = mix(uTransparency * 0.44, 0.92, opticalCoverage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uHasWallpaperTexture < 0.5) {
|
if (uHasWallpaperTexture < 0.5) {
|
||||||
@@ -819,9 +907,12 @@ void main() {
|
|||||||
`
|
`
|
||||||
|
|
||||||
const SCROLL_STABLE_TAIL_FRAMES = 2
|
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_MAX_FRAMES = 6
|
||||||
const SURFACE_STABILITY_REQUIRED_FRAMES = 2
|
const SURFACE_STABILITY_REQUIRED_FRAMES = 2
|
||||||
const FLOW_BUFFER_SCALE = 0.25
|
const FLOW_BUFFER_SCALE = 0.25
|
||||||
|
const FROST_PREFILTER_SCALE = 0.125
|
||||||
const SURFACE_TRANSITION_DURATION_MS = 96
|
const SURFACE_TRANSITION_DURATION_MS = 96
|
||||||
const SURFACE_TRANSFORM_TRACKING_MAX_MS = 1000
|
const SURFACE_TRANSFORM_TRACKING_MAX_MS = 1000
|
||||||
|
|
||||||
@@ -965,12 +1056,17 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
let three: ThreeModule | null = null
|
let three: ThreeModule | null = null
|
||||||
let resources: GlassRendererResources | null = null
|
let resources: GlassRendererResources | null = null
|
||||||
let flowResources: GlassFlowResources | null = null
|
let flowResources: GlassFlowResources | null = null
|
||||||
|
let frostPrefilterResources: GlassFrostPrefilterResources | null = null
|
||||||
let activeTexture: Texture | null = null
|
let activeTexture: Texture | null = null
|
||||||
|
let activeFrostedTarget: WebGLRenderTarget | null = null
|
||||||
let activeTextureHeight = 1
|
let activeTextureHeight = 1
|
||||||
let activeTextureWidth = 1
|
let activeTextureWidth = 1
|
||||||
|
let activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||||
let previousTexture: Texture | null = null
|
let previousTexture: Texture | null = null
|
||||||
|
let previousFrostedTarget: WebGLRenderTarget | null = null
|
||||||
let previousTextureHeight = 1
|
let previousTextureHeight = 1
|
||||||
let previousTextureWidth = 1
|
let previousTextureWidth = 1
|
||||||
|
let previousWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||||
let loadVersion = 0
|
let loadVersion = 0
|
||||||
let prepareVersion = 0
|
let prepareVersion = 0
|
||||||
let preparedWallpaper: PreparedWallpaperTexture | null = null
|
let preparedWallpaper: PreparedWallpaperTexture | null = null
|
||||||
@@ -999,6 +1095,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
let activeTouchIdentifier: number | null = null
|
let activeTouchIdentifier: number | null = null
|
||||||
let pendingFlowInjection = 0
|
let pendingFlowInjection = 0
|
||||||
let interactionAnimating = false
|
let interactionAnimating = false
|
||||||
|
let presentationResizeCandidate = ''
|
||||||
|
let presentationResizeStableSamples = 0
|
||||||
|
let presentationResizeTimer: number | null = null
|
||||||
let scrollAnimationFrame: number | null = null
|
let scrollAnimationFrame: number | null = null
|
||||||
let scrollDirty = false
|
let scrollDirty = false
|
||||||
let scrollSurfaceRefreshPending = false
|
let scrollSurfaceRefreshPending = false
|
||||||
@@ -1017,6 +1116,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
let surfaceTransformFrame: number | null = null
|
let surfaceTransformFrame: number | null = null
|
||||||
let surfaceTransformTrackingDeadline = 0
|
let surfaceTransformTrackingDeadline = 0
|
||||||
const transformingSurfaces = new Set<HTMLElement>()
|
const transformingSurfaces = new Set<HTMLElement>()
|
||||||
|
let pagePresentationGeometryReady = true
|
||||||
let wakeDirection = { x: 0, y: -1 }
|
let wakeDirection = { x: 0, y: -1 }
|
||||||
let contextRecoveryPending = false
|
let contextRecoveryPending = false
|
||||||
const presentationSpace = options.surfaceSpace ?? 'fixed'
|
const presentationSpace = options.surfaceSpace ?? 'fixed'
|
||||||
@@ -1095,16 +1195,28 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
backgroundDisposeTimer = null
|
backgroundDisposeTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 源纹理与磨砂预滤目标共享同一壁纸生命周期,必须成对释放。 */
|
||||||
|
function disposeWallpaperResources(texture: Texture | null, frostedTarget: WebGLRenderTarget | null) {
|
||||||
|
texture?.dispose()
|
||||||
|
frostedTarget?.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
/** 释放已完成过渡的旧纹理,并让两个采样槽继续指向同一稳定壁纸。 */
|
/** 释放已完成过渡的旧纹理,并让两个采样槽继续指向同一稳定壁纸。 */
|
||||||
function finishWallpaperTransition() {
|
function finishWallpaperTransition() {
|
||||||
if (!resources || !activeTexture) return
|
if (!resources || !activeTexture) return
|
||||||
|
|
||||||
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
|
if (previousTexture && previousTexture !== activeTexture) {
|
||||||
|
disposeWallpaperResources(previousTexture, previousFrostedTarget)
|
||||||
|
}
|
||||||
previousTexture = null
|
previousTexture = null
|
||||||
|
previousFrostedTarget = null
|
||||||
previousTextureHeight = activeTextureHeight
|
previousTextureHeight = activeTextureHeight
|
||||||
previousTextureWidth = activeTextureWidth
|
previousTextureWidth = activeTextureWidth
|
||||||
|
previousWallpaperExposure = activeWallpaperExposure
|
||||||
resources.uniforms.uPreviousTexture.value = activeTexture
|
resources.uniforms.uPreviousTexture.value = activeTexture
|
||||||
|
resources.uniforms.uPreviousFrostedTexture.value = activeFrostedTarget?.texture ?? activeTexture
|
||||||
resources.uniforms.uPreviousCoverScale.value.copy(resources.uniforms.uCoverScale.value)
|
resources.uniforms.uPreviousCoverScale.value.copy(resources.uniforms.uCoverScale.value)
|
||||||
|
resources.uniforms.uPreviousWallpaperExposure.value = activeWallpaperExposure
|
||||||
resources.uniforms.uTextureMix.value = 1
|
resources.uniforms.uTextureMix.value = 1
|
||||||
cancelWallpaperTransitionFrame()
|
cancelWallpaperTransitionFrame()
|
||||||
}
|
}
|
||||||
@@ -1141,6 +1253,91 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
|
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() {
|
function disposeFlowResources() {
|
||||||
if (!flowResources) return
|
if (!flowResources) return
|
||||||
@@ -1270,6 +1467,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const transitionWeights = outgoingSurface
|
const transitionWeights = outgoingSurface
|
||||||
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
|
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
|
||||||
: { incoming: 1, outgoing: 0 }
|
: { incoming: 1, outgoing: 0 }
|
||||||
|
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) {
|
for (let index = 0; index < 8; index += 1) {
|
||||||
const surface = normalized[index]
|
const surface = normalized[index]
|
||||||
@@ -1278,7 +1478,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const radii = surface?.radii ?? [0, 0, 0, 0]
|
const radii = surface?.radii ?? [0, 0, 0, 0]
|
||||||
uniformRects[index].set(rect[0], rect[1], rect[2], rect[3])
|
uniformRects[index].set(rect[0], rect[1], rect[2], rect[3])
|
||||||
uniformRadii[index].set(radii[0], radii[1], radii[2], radii[3])
|
uniformRadii[index].set(radii[0], radii[1], radii[2], radii[3])
|
||||||
uniformWeights[index] =
|
const surfaceWeight =
|
||||||
slot?.role === 'outgoing'
|
slot?.role === 'outgoing'
|
||||||
? transitionWeights.outgoing
|
? transitionWeights.outgoing
|
||||||
: slot?.role === 'active'
|
: slot?.role === 'active'
|
||||||
@@ -1286,6 +1486,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
: slot
|
: slot
|
||||||
? 1
|
? 1
|
||||||
: 0
|
: 0
|
||||||
|
uniformWeights[index] = surfaceWeight * pagePresentationWeight
|
||||||
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
|
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1373,21 +1574,76 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
surfaceStabilityPass < SURFACE_STABILITY_MAX_FRAMES
|
surfaceStabilityPass < SURFACE_STABILITY_MAX_FRAMES
|
||||||
) {
|
) {
|
||||||
surfaceStabilityFrame = requestAnimationFrame(sample)
|
surfaceStabilityFrame = requestAnimationFrame(sample)
|
||||||
|
} else if (!pagePresentationGeometryReady) {
|
||||||
|
pagePresentationGeometryReady = true
|
||||||
|
writeSurfaceUniforms(timestamp)
|
||||||
|
renderFrame(timestamp, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
surfaceStabilityFrame = requestAnimationFrame(sample)
|
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[]) {
|
function handleSurfaceResize(entries: ResizeObserverEntry[]) {
|
||||||
if (!resources) return
|
if (!resources) return
|
||||||
|
|
||||||
const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
|
const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
|
||||||
if (presentationRoot && entries.some(entry => entry.target === presentationRoot)) {
|
const presentationChanged = presentationRoot && entries.some(entry => entry.target === presentationRoot)
|
||||||
resizeRenderer()
|
if (presentationChanged && commitActivePagePresentation()) return
|
||||||
return
|
if (presentationChanged) schedulePresentationResizeUpdate()
|
||||||
}
|
if (!entries.some(entry => entry.target !== presentationRoot)) return
|
||||||
|
|
||||||
const timestamp = performance.now()
|
const timestamp = performance.now()
|
||||||
updateSurfaceUniforms(timestamp, false)
|
updateSurfaceUniforms(timestamp, false)
|
||||||
@@ -2054,6 +2310,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
void initializeRenderer(false)
|
void initializeRenderer(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWindowResize() {
|
||||||
|
if (presentationSpace === 'scroll') schedulePresentationResizeUpdate()
|
||||||
|
else resizeRenderer()
|
||||||
|
}
|
||||||
|
|
||||||
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
|
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
|
||||||
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
|
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
|
||||||
return mutations.some(
|
return mutations.some(
|
||||||
@@ -2083,7 +2344,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
surfaceMutationObserver = new MutationObserver(mutations => {
|
surfaceMutationObserver = new MutationObserver(mutations => {
|
||||||
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
|
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
|
||||||
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
|
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('.app-wrapper'), true)
|
||||||
observeMutationRoot(document.querySelector('.v-overlay-container'), 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('touchend', handleTouchEnd, { passive: true })
|
||||||
window.addEventListener('touchcancel', 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('transitionrun', handleSurfaceTransitionRun, { capture: true, passive: true })
|
||||||
window.addEventListener('transitionend', handleSurfaceTransitionEnd, { capture: true, passive: true })
|
window.addEventListener('transitionend', handleSurfaceTransitionEnd, { capture: true, passive: true })
|
||||||
window.addEventListener('transitioncancel', 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('touchend', handleTouchEnd)
|
||||||
window.removeEventListener('touchcancel', handleTouchEnd)
|
window.removeEventListener('touchcancel', handleTouchEnd)
|
||||||
}
|
}
|
||||||
window.removeEventListener('resize', resizeRenderer)
|
window.removeEventListener('resize', handleWindowResize)
|
||||||
window.removeEventListener('transitionrun', handleSurfaceTransitionRun, true)
|
window.removeEventListener('transitionrun', handleSurfaceTransitionRun, true)
|
||||||
window.removeEventListener('transitionend', handleSurfaceTransitionEnd, true)
|
window.removeEventListener('transitionend', handleSurfaceTransitionEnd, true)
|
||||||
window.removeEventListener('transitioncancel', handleSurfaceTransitionEnd, true)
|
window.removeEventListener('transitioncancel', handleSurfaceTransitionEnd, true)
|
||||||
@@ -2156,6 +2420,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
cancelAnimationFrame(surfaceStabilityFrame)
|
cancelAnimationFrame(surfaceStabilityFrame)
|
||||||
surfaceStabilityFrame = null
|
surfaceStabilityFrame = null
|
||||||
}
|
}
|
||||||
|
if (presentationResizeTimer !== null) {
|
||||||
|
window.clearTimeout(presentationResizeTimer)
|
||||||
|
presentationResizeTimer = null
|
||||||
|
}
|
||||||
|
presentationResizeCandidate = ''
|
||||||
|
presentationResizeStableSamples = 0
|
||||||
removeEvents()
|
removeEvents()
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
@@ -2180,19 +2450,28 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
pointerSpringVelocityY = 0
|
pointerSpringVelocityY = 0
|
||||||
pendingFlowInjection = 0
|
pendingFlowInjection = 0
|
||||||
lastInteractionFrameAt = 0
|
lastInteractionFrameAt = 0
|
||||||
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
|
if (previousTexture && previousTexture !== activeTexture) {
|
||||||
|
disposeWallpaperResources(previousTexture, previousFrostedTarget)
|
||||||
|
}
|
||||||
previousTexture = null
|
previousTexture = null
|
||||||
|
previousFrostedTarget = null
|
||||||
previousTextureHeight = 1
|
previousTextureHeight = 1
|
||||||
previousTextureWidth = 1
|
previousTextureWidth = 1
|
||||||
activeTexture?.dispose()
|
previousWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||||
|
disposeWallpaperResources(activeTexture, activeFrostedTarget)
|
||||||
activeTexture = null
|
activeTexture = null
|
||||||
|
activeFrostedTarget = null
|
||||||
activeTextureHeight = 1
|
activeTextureHeight = 1
|
||||||
activeTextureWidth = 1
|
activeTextureWidth = 1
|
||||||
preparedWallpaper?.texture.dispose()
|
activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||||
|
if (preparedWallpaper) {
|
||||||
|
disposeWallpaperResources(preparedWallpaper.texture, preparedWallpaper.frostedTarget)
|
||||||
|
}
|
||||||
preparedWallpaper = null
|
preparedWallpaper = null
|
||||||
preparedWallpaperUrl.value = ''
|
preparedWallpaperUrl.value = ''
|
||||||
|
|
||||||
disposeFlowResources()
|
disposeFlowResources()
|
||||||
|
disposeFrostPrefilterResources()
|
||||||
if (resources) {
|
if (resources) {
|
||||||
resources.geometry.dispose()
|
resources.geometry.dispose()
|
||||||
resources.material.dispose()
|
resources.material.dispose()
|
||||||
@@ -2237,9 +2516,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 在同一 renderer 内交接新旧纹理;只有真实壁纸轮换才启用双纹理时钟。 */
|
/** 在同一 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) {
|
if (!resources) {
|
||||||
texture.dispose()
|
disposeWallpaperResources(texture, frostedTarget)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2250,15 +2536,25 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
cancelWallpaperTransitionFrame()
|
cancelWallpaperTransitionFrame()
|
||||||
|
|
||||||
if (hasActiveTransition && activeTexture) {
|
if (hasActiveTransition && activeTexture) {
|
||||||
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
|
if (previousTexture && previousTexture !== activeTexture) {
|
||||||
|
disposeWallpaperResources(previousTexture, previousFrostedTarget)
|
||||||
|
}
|
||||||
previousTexture = activeTexture
|
previousTexture = activeTexture
|
||||||
|
previousFrostedTarget = activeFrostedTarget
|
||||||
previousTextureHeight = activeTextureHeight
|
previousTextureHeight = activeTextureHeight
|
||||||
previousTextureWidth = activeTextureWidth
|
previousTextureWidth = activeTextureWidth
|
||||||
|
previousWallpaperExposure = activeWallpaperExposure
|
||||||
activeTexture = texture
|
activeTexture = texture
|
||||||
|
activeFrostedTarget = frostedTarget
|
||||||
activeTextureHeight = height
|
activeTextureHeight = height
|
||||||
activeTextureWidth = width
|
activeTextureWidth = width
|
||||||
|
activeWallpaperExposure = toneProfile.exposure
|
||||||
resources.uniforms.uPreviousTexture.value = previousTexture
|
resources.uniforms.uPreviousTexture.value = previousTexture
|
||||||
|
resources.uniforms.uPreviousFrostedTexture.value = previousFrostedTarget?.texture ?? previousTexture
|
||||||
resources.uniforms.uTexture.value = activeTexture
|
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(
|
resources.uniforms.uTextureMix.value = getGlassWallpaperTransitionProgress(
|
||||||
performance.now() - toValue(options.transitionStartedAt ?? 0),
|
performance.now() - toValue(options.transitionStartedAt ?? 0),
|
||||||
toValue(options.transitionDuration ?? 0),
|
toValue(options.transitionDuration ?? 0),
|
||||||
@@ -2266,21 +2562,32 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
syncCoverScale()
|
syncCoverScale()
|
||||||
scheduleWallpaperTransition()
|
scheduleWallpaperTransition()
|
||||||
} else {
|
} else {
|
||||||
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
|
if (previousTexture && previousTexture !== activeTexture) {
|
||||||
activeTexture?.dispose()
|
disposeWallpaperResources(previousTexture, previousFrostedTarget)
|
||||||
|
}
|
||||||
|
disposeWallpaperResources(activeTexture, activeFrostedTarget)
|
||||||
previousTexture = null
|
previousTexture = null
|
||||||
|
previousFrostedTarget = null
|
||||||
activeTexture = texture
|
activeTexture = texture
|
||||||
|
activeFrostedTarget = frostedTarget
|
||||||
activeTextureHeight = height
|
activeTextureHeight = height
|
||||||
activeTextureWidth = width
|
activeTextureWidth = width
|
||||||
|
activeWallpaperExposure = toneProfile.exposure
|
||||||
previousTextureHeight = height
|
previousTextureHeight = height
|
||||||
previousTextureWidth = width
|
previousTextureWidth = width
|
||||||
|
previousWallpaperExposure = toneProfile.exposure
|
||||||
resources.uniforms.uPreviousTexture.value = texture
|
resources.uniforms.uPreviousTexture.value = texture
|
||||||
|
resources.uniforms.uPreviousFrostedTexture.value = frostedTarget?.texture ?? texture
|
||||||
resources.uniforms.uTexture.value = 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
|
resources.uniforms.uTextureMix.value = 1
|
||||||
syncCoverScale()
|
syncCoverScale()
|
||||||
}
|
}
|
||||||
|
|
||||||
resources.uniforms.uHasWallpaperTexture.value = hasWallpaperTexture ? 1 : 0
|
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.generateMipmaps = false
|
||||||
texture.minFilter = three.LinearFilter
|
texture.minFilter = three.LinearFilter
|
||||||
texture.magFilter = 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()
|
const loader = new three.TextureLoader()
|
||||||
@@ -2333,10 +2647,19 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
texture.generateMipmaps = false
|
texture.generateMipmaps = false
|
||||||
texture.minFilter = three.LinearFilter
|
texture.minFilter = three.LinearFilter
|
||||||
texture.magFilter = three.LinearFilter
|
texture.magFilter = three.LinearFilter
|
||||||
|
let frostedTarget: WebGLRenderTarget | null = null
|
||||||
|
try {
|
||||||
|
frostedTarget = await createFrostedWallpaperTarget(texture, textureWidth, textureHeight)
|
||||||
|
} catch (error) {
|
||||||
|
// 预滤属于磨砂优化;失败时保留源纹理并退回既有扩散采样,不能拖垮其他材质。
|
||||||
|
console.warn('玻璃磨砂壁纸预滤失败,继续使用实时扩散采样:', error)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
frostedTarget,
|
||||||
hasWallpaperTexture: true,
|
hasWallpaperTexture: true,
|
||||||
height: textureHeight,
|
height: textureHeight,
|
||||||
texture,
|
texture,
|
||||||
|
toneProfile: analyzeGlassWallpaperTone(image, sourceWidth, sourceHeight),
|
||||||
width: textureWidth,
|
width: textureWidth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2344,7 +2667,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
/** 提前准备下一张纹理;失败不会影响当前活动纹理。 */
|
/** 提前准备下一张纹理;失败不会影响当前活动纹理。 */
|
||||||
async function prepareWallpaper(url: string) {
|
async function prepareWallpaper(url: string) {
|
||||||
const version = ++prepareVersion
|
const version = ++prepareVersion
|
||||||
preparedWallpaper?.texture.dispose()
|
if (preparedWallpaper) {
|
||||||
|
disposeWallpaperResources(preparedWallpaper.texture, preparedWallpaper.frostedTarget)
|
||||||
|
}
|
||||||
preparedWallpaper = null
|
preparedWallpaper = null
|
||||||
preparedWallpaperUrl.value = ''
|
preparedWallpaperUrl.value = ''
|
||||||
if (!url || !resources || url === toValue(options.wallpaperUrl)) {
|
if (!url || !resources || url === toValue(options.wallpaperUrl)) {
|
||||||
@@ -2356,7 +2681,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const prepared = await createWallpaperTexture(url)
|
const prepared = await createWallpaperTexture(url)
|
||||||
if (!prepared) return
|
if (!prepared) return
|
||||||
if (version !== prepareVersion || !resources) {
|
if (version !== prepareVersion || !resources) {
|
||||||
prepared.texture.dispose()
|
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2379,11 +2704,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
preparedWallpaperUrl.value = ''
|
preparedWallpaperUrl.value = ''
|
||||||
}
|
}
|
||||||
if (version !== loadVersion || !resources) {
|
if (version !== loadVersion || !resources) {
|
||||||
prepared.texture.dispose()
|
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||||
return
|
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)
|
await resources.renderer.compileAsync(resources.scene, resources.camera)
|
||||||
if (version !== loadVersion || !resources) return
|
if (version !== loadVersion || !resources) return
|
||||||
|
|
||||||
@@ -2427,6 +2759,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
uFlowTexture: { value: null },
|
uFlowTexture: { value: null },
|
||||||
uFlowStrength: { value: getFlowStrengthScale() },
|
uFlowStrength: { value: getFlowStrengthScale() },
|
||||||
uHasFlowTexture: { value: 0 },
|
uHasFlowTexture: { value: 0 },
|
||||||
|
uHasFrostedTexture: { value: 0 },
|
||||||
uHasWallpaperTexture: { value: 0 },
|
uHasWallpaperTexture: { value: 0 },
|
||||||
uMotion: { value: 0 },
|
uMotion: { value: 0 },
|
||||||
uMotionExpansion: { value: getMotionExpansion() },
|
uMotionExpansion: { value: getMotionExpansion() },
|
||||||
@@ -2435,6 +2768,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
uPointerVelocity: { value: new three.Vector2(0, 0) },
|
uPointerVelocity: { value: new three.Vector2(0, 0) },
|
||||||
uPresentationSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) },
|
uPresentationSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) },
|
||||||
uPreviousCoverScale: { value: new three.Vector2(1, 1) },
|
uPreviousCoverScale: { value: new three.Vector2(1, 1) },
|
||||||
|
uPreviousWallpaperExposure: { value: DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure },
|
||||||
uQuality: { value: toValue(options.quality) === 'high' ? 1 : 0 },
|
uQuality: { value: toValue(options.quality) === 'high' ? 1 : 0 },
|
||||||
uReflectionStrength: { value: getReflectionStrengthScale() },
|
uReflectionStrength: { value: getReflectionStrengthScale() },
|
||||||
uRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
|
uRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
|
||||||
@@ -2443,8 +2777,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
|
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
|
||||||
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
|
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
|
||||||
uPreviousTexture: { value: null },
|
uPreviousTexture: { value: null },
|
||||||
|
uPreviousFrostedTexture: { value: null },
|
||||||
uTexture: { value: null },
|
uTexture: { value: null },
|
||||||
|
uFrostedTexture: { value: null },
|
||||||
uTextureMix: { value: 1 },
|
uTextureMix: { value: 1 },
|
||||||
|
uWallpaperExposure: { value: DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure },
|
||||||
uTintColor: { value: new three.Color(toValue(options.tintColor)) },
|
uTintColor: { value: new three.Color(toValue(options.tintColor)) },
|
||||||
uTransparency: { value: getTransparency() },
|
uTransparency: { value: getTransparency() },
|
||||||
uTransmissionStrength: {
|
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(
|
watch(
|
||||||
() => toValue(options.routeKey),
|
() => toValue(options.routeKey),
|
||||||
async (routeKey, previousRouteKey) => {
|
async (routeKey, previousRouteKey) => {
|
||||||
const previousProfile = getRenderProfile(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) {
|
if (resources) {
|
||||||
const nextProfile = getRenderProfile(routeKey)
|
const nextProfile = getRenderProfile(routeKey)
|
||||||
resizeRenderer()
|
resizeRenderer()
|
||||||
@@ -2636,9 +2994,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scheduleSurfaceUpdate()
|
if (presentationSpace === 'scroll' && options.pageMotion) scheduleSurfaceStabilityUpdate()
|
||||||
|
else scheduleSurfaceUpdate()
|
||||||
},
|
},
|
||||||
{ flush: 'post' },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
onScopeDispose(() => {
|
onScopeDispose(() => {
|
||||||
|
|||||||
@@ -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>
|
<script lang="ts" setup>
|
||||||
import DefaultLayout from './default/components/DefaultLayout.vue'
|
import DefaultLayout from './default/components/DefaultLayout.vue'
|
||||||
|
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const pagePresentationMotion = usePagePresentationMotion()
|
||||||
|
|
||||||
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
|
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
|
||||||
const routeCacheKey = computed(() => {
|
const routeCacheKey = computed(() => {
|
||||||
@@ -16,6 +18,7 @@ const routeCacheKey = computed(() => {
|
|||||||
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
|
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
|
||||||
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
|
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
|
||||||
const isPageEntering = ref(false)
|
const isPageEntering = ref(false)
|
||||||
|
const pageRouteRef = ref<HTMLElement | null>(null)
|
||||||
let pageMotionTimer: number | null = null
|
let pageMotionTimer: number | null = null
|
||||||
let pageMotionFrame: number | null = null
|
let pageMotionFrame: number | null = null
|
||||||
|
|
||||||
@@ -32,9 +35,11 @@ function playPageEnterMotion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isPageEntering.value = false
|
isPageEntering.value = false
|
||||||
|
if (pagePresentationMotion.start(routeTransitionKey.value, pageRouteRef.value)) return
|
||||||
|
|
||||||
pageMotionFrame = window.requestAnimationFrame(() => {
|
pageMotionFrame = window.requestAnimationFrame(() => {
|
||||||
isPageEntering.value = true
|
|
||||||
pageMotionFrame = null
|
pageMotionFrame = null
|
||||||
|
isPageEntering.value = true
|
||||||
pageMotionTimer = window.setTimeout(() => {
|
pageMotionTimer = window.setTimeout(() => {
|
||||||
isPageEntering.value = false
|
isPageEntering.value = false
|
||||||
pageMotionTimer = null
|
pageMotionTimer = null
|
||||||
@@ -49,13 +54,14 @@ onMounted(playPageEnterMotion)
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (pageMotionTimer) window.clearTimeout(pageMotionTimer)
|
if (pageMotionTimer) window.clearTimeout(pageMotionTimer)
|
||||||
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
|
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
|
||||||
|
pagePresentationMotion.cancel()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DefaultLayout>
|
<DefaultLayout>
|
||||||
<router-view v-slot="{ Component }">
|
<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">
|
<keep-alive :max="24">
|
||||||
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
|
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
grid,
|
grid,
|
||||||
gridInit: vi.fn<(options: unknown, element: unknown) => unknown>(() => grid),
|
gridInit: vi.fn<(options: unknown, element: unknown) => unknown>(() => grid),
|
||||||
openSharedDialog: vi.fn(),
|
openSharedDialog: vi.fn(),
|
||||||
|
themeName: undefined as unknown as { value: string },
|
||||||
useDynamicButton: vi.fn(),
|
useDynamicButton: vi.fn(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -76,10 +77,12 @@ vi.mock('@/api', () => ({
|
|||||||
vi.mock('vuetify', async importOriginal => {
|
vi.mock('vuetify', async importOriginal => {
|
||||||
const { ref } = await import('vue')
|
const { ref } = await import('vue')
|
||||||
mocks.displayWidth = ref(1512)
|
mocks.displayWidth = ref(1512)
|
||||||
|
mocks.themeName = ref('light')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(await importOriginal<typeof import('vuetify')>()),
|
...(await importOriginal<typeof import('vuetify')>()),
|
||||||
useDisplay: () => ({ width: mocks.displayWidth }),
|
useDisplay: () => ({ width: mocks.displayWidth }),
|
||||||
|
useTheme: () => ({ global: { name: mocks.themeName } }),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -184,6 +187,7 @@ describe('dashboard page initial layout', () => {
|
|||||||
mocks.apiGet.mockReset()
|
mocks.apiGet.mockReset()
|
||||||
mocks.apiPost.mockReset()
|
mocks.apiPost.mockReset()
|
||||||
mocks.displayWidth.value = 1512
|
mocks.displayWidth.value = 1512
|
||||||
|
mocks.themeName.value = 'light'
|
||||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -210,9 +214,9 @@ describe('dashboard page initial layout', () => {
|
|||||||
|
|
||||||
expect(screen.getAllByTestId('dashboard-item')).toHaveLength(1)
|
expect(screen.getAllByTestId('dashboard-item')).toHaveLength(1)
|
||||||
expect(screen.getByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'systemInfo')
|
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(mocks.grid.setAnimation).toHaveBeenCalledWith(true))
|
||||||
await waitFor(() => expect(container.querySelector('.dashboard-grid')).toHaveClass('is-revealed'))
|
|
||||||
|
|
||||||
remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } })
|
remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } })
|
||||||
remoteProfile.resolve({
|
remoteProfile.resolve({
|
||||||
@@ -228,6 +232,26 @@ describe('dashboard page initial layout', () => {
|
|||||||
await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled())
|
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 () => {
|
it('keeps the upstream progressive default while an uncached remote profile is loading', async () => {
|
||||||
const remoteOrder = deferred<unknown>()
|
const remoteOrder = deferred<unknown>()
|
||||||
const remoteProfile = deferred<unknown>()
|
const remoteProfile = deferred<unknown>()
|
||||||
@@ -241,7 +265,7 @@ describe('dashboard page initial layout', () => {
|
|||||||
await renderDashboard()
|
await renderDashboard()
|
||||||
|
|
||||||
expect(screen.getAllByTestId('dashboard-item')).toHaveLength(10)
|
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: '' }] } })
|
remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } })
|
||||||
remoteProfile.resolve({
|
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.column).toHaveBeenCalledWith(1, 'list'))
|
||||||
await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(1))
|
await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(1))
|
||||||
await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(10))
|
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([
|
expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([
|
||||||
'storage',
|
'storage',
|
||||||
'mediaStatistic',
|
'mediaStatistic',
|
||||||
|
|||||||
+19
-42
@@ -12,7 +12,7 @@ import { usePWA } from '@/composables/usePWA'
|
|||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay, useTheme } from 'vuetify'
|
||||||
|
|
||||||
const ContentToggleSettingsDialog = defineAsyncComponent(
|
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||||||
@@ -24,6 +24,7 @@ const { t } = useI18n()
|
|||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
const vuetifyTheme = useTheme()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
|
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
|
||||||
@@ -97,9 +98,6 @@ interface DashboardGridItem {
|
|||||||
// 是否处于仪表板布局编辑模式
|
// 是否处于仪表板布局编辑模式
|
||||||
const isLayoutEditing = ref(false)
|
const isLayoutEditing = ref(false)
|
||||||
|
|
||||||
// 首次布局完成后触发轻量整体入场,不延迟或隐藏渐进渲染内容。
|
|
||||||
const isDashboardGridRevealed = ref(false)
|
|
||||||
|
|
||||||
// 是否发送请求的总开关
|
// 是否发送请求的总开关
|
||||||
const isRequest = ref(true)
|
const isRequest = ref(true)
|
||||||
|
|
||||||
@@ -148,7 +146,6 @@ let dashboardGridContentObserver: ResizeObserver | null = null
|
|||||||
let dashboardGridContentResizeFrame: number | null = null
|
let dashboardGridContentResizeFrame: number | null = null
|
||||||
let dashboardGridResizeRefreshFrame: number | null = null
|
let dashboardGridResizeRefreshFrame: number | null = null
|
||||||
let dashboardGridAnimationFrame: number | null = null
|
let dashboardGridAnimationFrame: number | null = null
|
||||||
let dashboardGridEntranceFrame: number | null = null
|
|
||||||
let dashboardRevealFrame: number | null = null
|
let dashboardRevealFrame: number | null = null
|
||||||
let isDashboardRevealPending = false
|
let isDashboardRevealPending = false
|
||||||
let dashboardProfileSaveQueue = Promise.resolve()
|
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() {
|
function pauseDashboardGridAnimation() {
|
||||||
if (dashboardGridAnimationFrame !== null) {
|
if (dashboardGridAnimationFrame !== null) {
|
||||||
@@ -408,15 +395,21 @@ function pauseDashboardGridAnimation() {
|
|||||||
dashboardGrid.value?.setAnimation(false)
|
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() {
|
function scheduleDashboardGridAnimationResume() {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
if (dashboardGridAnimationFrame !== null) cancelAnimationFrame(dashboardGridAnimationFrame)
|
if (dashboardGridAnimationFrame !== null) cancelAnimationFrame(dashboardGridAnimationFrame)
|
||||||
|
|
||||||
dashboardGridAnimationFrame = requestAnimationFrame(() => {
|
dashboardGridAnimationFrame = requestAnimationFrame(() => {
|
||||||
dashboardGridAnimationFrame = null
|
dashboardGridAnimationFrame = null
|
||||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
dashboardGrid.value?.setAnimation(shouldAnimateDashboardGrid())
|
||||||
dashboardGrid.value?.setAnimation(!reduceMotion)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1192,7 +1185,7 @@ function initializeDashboardGrid() {
|
|||||||
|
|
||||||
dashboardGrid.value = GridStack.init(
|
dashboardGrid.value = GridStack.init(
|
||||||
{
|
{
|
||||||
animate: false,
|
animate: shouldAnimateDashboardGrid(),
|
||||||
cellHeight: DASHBOARD_GRID_CELL_HEIGHT,
|
cellHeight: DASHBOARD_GRID_CELL_HEIGHT,
|
||||||
column: getDashboardGridColumnsForProfile(dashboardLayoutProfile.value),
|
column: getDashboardGridColumnsForProfile(dashboardLayoutProfile.value),
|
||||||
draggable: {
|
draggable: {
|
||||||
@@ -1222,6 +1215,7 @@ function updateDashboardGridEditableState(editable: boolean) {
|
|||||||
if (!dashboardGrid.value) return
|
if (!dashboardGrid.value) return
|
||||||
|
|
||||||
dashboardGrid.value.setStatic(!editable)
|
dashboardGrid.value.setStatic(!editable)
|
||||||
|
dashboardGrid.value.setAnimation(shouldAnimateDashboardGrid(editable))
|
||||||
if (editable) {
|
if (editable) {
|
||||||
dashboardGrid.value.enableMove(true)
|
dashboardGrid.value.enableMove(true)
|
||||||
dashboardGrid.value.enableResize(true)
|
dashboardGrid.value.enableResize(true)
|
||||||
@@ -1306,7 +1300,6 @@ async function syncDashboardGrid(resumeAnimation = true) {
|
|||||||
resizeAutoDashboardItemsToContent()
|
resizeAutoDashboardItemsToContent()
|
||||||
scheduleDashboardReveal()
|
scheduleDashboardReveal()
|
||||||
})
|
})
|
||||||
scheduleDashboardGridEntrance()
|
|
||||||
} finally {
|
} finally {
|
||||||
isSyncingDashboardGrid.value = false
|
isSyncingDashboardGrid.value = false
|
||||||
if (resumeAnimation) scheduleDashboardGridAnimationResume()
|
if (resumeAnimation) scheduleDashboardGridAnimationResume()
|
||||||
@@ -1547,6 +1540,11 @@ watch(isLayoutEditing, value => {
|
|||||||
updateDashboardGridEditableState(value)
|
updateDashboardGridEditableState(value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => vuetifyTheme.global.name.value,
|
||||||
|
() => updateDashboardGridEditableState(isLayoutEditing.value),
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
dashboardGridItems,
|
dashboardGridItems,
|
||||||
() => {
|
() => {
|
||||||
@@ -1664,10 +1662,6 @@ onBeforeUnmount(() => {
|
|||||||
cancelAnimationFrame(dashboardGridAnimationFrame)
|
cancelAnimationFrame(dashboardGridAnimationFrame)
|
||||||
dashboardGridAnimationFrame = null
|
dashboardGridAnimationFrame = null
|
||||||
}
|
}
|
||||||
if (dashboardGridEntranceFrame !== null) {
|
|
||||||
cancelAnimationFrame(dashboardGridEntranceFrame)
|
|
||||||
dashboardGridEntranceFrame = null
|
|
||||||
}
|
|
||||||
if (dashboardRevealFrame !== null) {
|
if (dashboardRevealFrame !== null) {
|
||||||
cancelAnimationFrame(dashboardRevealFrame)
|
cancelAnimationFrame(dashboardRevealFrame)
|
||||||
dashboardRevealFrame = null
|
dashboardRevealFrame = null
|
||||||
@@ -1685,7 +1679,7 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
ref="dashboardGridRef"
|
ref="dashboardGridRef"
|
||||||
class="grid-stack dashboard-grid"
|
class="grid-stack dashboard-grid"
|
||||||
:class="{ 'is-editing': isLayoutEditing, 'is-revealed': isDashboardGridRevealed }"
|
:class="{ 'is-editing': isLayoutEditing }"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="gridItem in dashboardGridItems"
|
v-for="gridItem in dashboardGridItems"
|
||||||
@@ -1753,17 +1747,7 @@ onBeforeUnmount(() => {
|
|||||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
opacity: 0.92;
|
|
||||||
pointer-events: auto;
|
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) {
|
.dashboard-grid :deep(.v-card) {
|
||||||
@@ -1893,11 +1877,4 @@ onBeforeUnmount(() => {
|
|||||||
inset-inline-end: -4px;
|
inset-inline-end: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.dashboard-grid {
|
|
||||||
opacity: 1;
|
|
||||||
transform: none;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -237,39 +237,29 @@ onActivated(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||||
<VWindowItem value="themoviedb">
|
<VWindowItem value="themoviedb">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<TheMovieDbView />
|
<TheMovieDbView />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
<VWindowItem value="douban">
|
<VWindowItem value="douban">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<DoubanView />
|
<DoubanView />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
<VWindowItem value="bangumi">
|
<VWindowItem value="bangumi">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<BangumiView />
|
<BangumiView />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
<VWindowItem value="anilist">
|
<VWindowItem value="anilist">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<AniListView />
|
<AniListView />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
|
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<ExtraSourceView :source="item" />
|
<ExtraSourceView :source="item" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
<!-- 快速滚动到顶部按钮 -->
|
<!-- 快速滚动到顶部按钮 -->
|
||||||
|
|||||||
@@ -55,11 +55,9 @@ useKeepAliveRefresh(async () => {
|
|||||||
<div v-if="downloaders.length > 0">
|
<div v-if="downloaders.length > 0">
|
||||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||||
<VWindowItem v-for="item in downloaders" :key="item.name" :value="item.name">
|
<VWindowItem v-for="item in downloaders" :key="item.name" :value="item.name">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
|
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1317,7 +1317,7 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div data-glass-optical-mode="static-material">
|
||||||
<!-- 搜索加载状态 -->
|
<!-- 搜索加载状态 -->
|
||||||
<VFadeTransition>
|
<VFadeTransition>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -64,15 +64,9 @@ watch(activeTab, markTabVisited, { immediate: true })
|
|||||||
<div>
|
<div>
|
||||||
<VWindow v-model="activeTab" class="settings-content-window disable-tab-transition" :touch="false">
|
<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">
|
<VWindowItem v-for="item in settingTabComponents" :key="item.value" :value="item.value">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<component
|
<component :is="item.component" v-if="visitedTabs.has(item.value)" :active="activeTab === item.value" />
|
||||||
:is="item.component"
|
|
||||||
v-if="visitedTabs.has(item.value)"
|
|
||||||
:active="activeTab === item.value"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-11
@@ -342,9 +342,7 @@ const subscribeDynamicMenuItems = computed<DynamicButtonMenuItem[] | undefined>(
|
|||||||
action: () => {},
|
action: () => {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: subscribeBatchState.value.allSelected
|
titleKey: subscribeBatchState.value.allSelected ? 'subscribe.batchDeselectAll' : 'subscribe.batchSelectAll',
|
||||||
? 'subscribe.batchDeselectAll'
|
|
||||||
: 'subscribe.batchSelectAll',
|
|
||||||
icon: subscribeBatchState.value.allSelected ? 'mdi-checkbox-blank-outline' : 'mdi-checkbox-multiple-marked',
|
icon: subscribeBatchState.value.allSelected ? 'mdi-checkbox-blank-outline' : 'mdi-checkbox-multiple-marked',
|
||||||
permission: 'subscribe',
|
permission: 'subscribe',
|
||||||
disabled: subscribeBatchState.value.totalCount === 0,
|
disabled: subscribeBatchState.value.totalCount === 0,
|
||||||
@@ -442,7 +440,9 @@ useDynamicButton({
|
|||||||
menuItems: subscribeDynamicMenuItems,
|
menuItems: subscribeDynamicMenuItems,
|
||||||
permission: 'subscribe',
|
permission: 'subscribe',
|
||||||
show: computed(
|
show: computed(
|
||||||
() => appMode.value && (subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
|
() =>
|
||||||
|
appMode.value &&
|
||||||
|
(subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -519,7 +519,6 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
|
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
|
||||||
<VWindowItem value="mysub">
|
<VWindowItem value="mysub">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<SubscribeListView
|
<SubscribeListView
|
||||||
ref="subscribeListViewRef"
|
ref="subscribeListViewRef"
|
||||||
@@ -535,21 +534,16 @@ onMounted(() => {
|
|||||||
@batch-state-change="handleSubscribeBatchStateChange"
|
@batch-state-change="handleSubscribeBatchStateChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
<VWindowItem value="popular">
|
<VWindowItem value="popular">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<SubscribePopularView :type="subType" />
|
<SubscribePopularView :type="subType" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
<VWindowItem value="share">
|
<VWindowItem value="share">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<SubscribeShareView :keyword="shareKeyword" />
|
<SubscribeShareView :keyword="shareKeyword" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
|
|
||||||
@@ -722,7 +716,6 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -107,18 +107,14 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
|
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
|
||||||
<VWindowItem value="list">
|
<VWindowItem value="list">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<WorkflowListView ref="workflowListViewRef" />
|
<WorkflowListView ref="workflowListViewRef" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
<VWindowItem value="share">
|
<VWindowItem value="share">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
|
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
|
|
||||||
|
|||||||
@@ -168,9 +168,9 @@ html[data-theme='glass'] {
|
|||||||
|
|
||||||
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
|
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
|
||||||
&[data-glass-appearance='frosted'] {
|
&[data-glass-appearance='frosted'] {
|
||||||
--glass-surface: rgba(255, 255, 255, calc(0.12 - var(--glass-transparency, 0.5) * 0.07));
|
--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.11 - var(--glass-transparency, 0.5) * 0.06));
|
--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.16 - 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: rgba(255, 255, 255, 9%);
|
||||||
--glass-control-prominent: rgba(255, 255, 255, 10%);
|
--glass-control-prominent: rgba(255, 255, 255, 10%);
|
||||||
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
|
--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))
|
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
|
||||||
brightness(var(--glass-transmission-brightness));
|
brightness(var(--glass-transmission-brightness));
|
||||||
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
|
--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-blur: var(--glass-blur-raised);
|
||||||
--glass-overlay-saturate: var(--glass-saturate);
|
--glass-overlay-saturate: var(--glass-saturate);
|
||||||
--glass-blur-surface: 28px;
|
--glass-blur-surface: 40px;
|
||||||
--glass-saturate: 150%;
|
--glass-blur: 40px;
|
||||||
|
--glass-blur-raised: 60px;
|
||||||
|
--glass-saturate: 180%;
|
||||||
--glass-control-icon-color: rgba(242, 245, 250, 78%);
|
--glass-control-icon-color: rgba(242, 245, 250, 78%);
|
||||||
--glass-control-placeholder-color: rgba(242, 245, 250, 62%);
|
--glass-control-placeholder-color: rgba(242, 245, 250, 62%);
|
||||||
--glass-control-shortcut-background: rgba(255, 255, 255, 11%);
|
--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))
|
--glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate))
|
||||||
brightness(var(--glass-transmission-brightness));
|
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 > :where(.v-card, .v-sheet),
|
||||||
.v-overlay__content > form > :where(.v-card, .v-sheet) {
|
.v-overlay__content > form > :where(.v-card, .v-sheet) {
|
||||||
@@ -302,36 +311,21 @@ html[data-theme='glass'] {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面路由只使用短距离透明度与位移反馈,避免触发整页滤镜重采样。
|
// 页面 DOM 与 scroll renderer 共用同一 motion 时钟;canvas 本身保持壁纸坐标不动。
|
||||||
.mp-page-route--entering {
|
.mp-page-route--entering {
|
||||||
animation: glass-page-route-enter var(--mp-motion-duration-page) var(--mp-motion-ease-standard) both;
|
animation: none;
|
||||||
will-change: opacity, transform;
|
filter: none;
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glass-page-route-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0.82;
|
|
||||||
transform: translate3d(0, 2px, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
will-change: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mp-page-enter-active,
|
&[data-page-presentation-motion='active'] .mp-page-route {
|
||||||
.mp-page-leave-active {
|
filter: none;
|
||||||
filter: none !important;
|
opacity: var(--mp-page-motion-opacity, 1);
|
||||||
transition:
|
transform: translate3d(0, var(--mp-page-motion-translate-y, 0), 0);
|
||||||
opacity var(--mp-motion-duration-page) var(--mp-motion-ease-standard),
|
transform-origin: center top;
|
||||||
transform var(--mp-motion-duration-page) var(--mp-motion-ease-standard);
|
will-change: opacity, transform;
|
||||||
}
|
|
||||||
|
|
||||||
.mp-page-enter-from {
|
|
||||||
filter: none !important;
|
|
||||||
opacity: 0.82;
|
|
||||||
transform: translate3d(0, 2px, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(
|
:where(
|
||||||
@@ -827,13 +821,6 @@ html[data-theme='glass'] {
|
|||||||
--mobile-calendar-surface-blur: var(--glass-surface-backdrop-filter);
|
--mobile-calendar-surface-blur: var(--glass-surface-backdrop-filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 磨砂卡片不参与仪表板整体透明度入场,避免合成层切换造成先透明后模糊的闪变。
|
|
||||||
&[data-glass-appearance='frosted'] .dashboard-grid {
|
|
||||||
opacity: 1;
|
|
||||||
transform: none;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户权限编辑器由普通容器构成,通过主题变量接入玻璃表面材质。
|
// 用户权限编辑器由普通容器构成,通过主题变量接入玻璃表面材质。
|
||||||
.user-permission-editor {
|
.user-permission-editor {
|
||||||
--permission-editor-border: 1px solid var(--glass-border);
|
--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-quality='high']
|
||||||
)[data-glass-renderer-state='ready'] {
|
)[data-glass-renderer-state='ready'] {
|
||||||
--glass-navbar-backdrop-filter: none;
|
--glass-navbar-backdrop-filter: none;
|
||||||
--glass-overlay-blur: 8px;
|
--glass-overlay-blur: 0px;
|
||||||
--glass-overlay-saturate: 145%;
|
--glass-overlay-saturate: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
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(0)).toBe(0)
|
||||||
expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1)
|
expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1)
|
||||||
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
|
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(50)).toBeGreaterThan(0.6)
|
||||||
expect(getGlassOpticalTransparency(70)).toBeCloseTo(0.96)
|
expect(getGlassOpticalTransparency(70)).toBeCloseTo(0.96)
|
||||||
expect(getGlassOpticalTransparency(100)).toBeCloseTo(1.1)
|
expect(getGlassOpticalTransparency(100)).toBeCloseTo(1.1)
|
||||||
@@ -76,9 +76,9 @@ describe('glass optics geometry', () => {
|
|||||||
expect(getGlassOpticalTransmissionStrength(0)).toBe(0)
|
expect(getGlassOpticalTransmissionStrength(0)).toBe(0)
|
||||||
expect(getGlassOpticalTransmissionStrength(70)).toBe(1)
|
expect(getGlassOpticalTransmissionStrength(70)).toBe(1)
|
||||||
expect(getGlassOpticalTransmissionStrength(100)).toBe(1.3)
|
expect(getGlassOpticalTransmissionStrength(100)).toBe(1.3)
|
||||||
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.82)
|
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.84)
|
||||||
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1.28)
|
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1)
|
||||||
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.48)
|
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.08)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps presets as concrete six-parameter values', () => {
|
it('keeps presets as concrete six-parameter values', () => {
|
||||||
|
|||||||
@@ -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) {
|
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
|
||||||
const progress = 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 =
|
const highRangeProgress =
|
||||||
@@ -299,19 +299,19 @@ export function getGlassOpticalTransparency(value: unknown) {
|
|||||||
return 0.96 + 0.14 * highRangeProgress ** 1.35
|
return 0.96 + 0.14 * highRangeProgress ** 1.35
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 标准 CSS 材质使用受控亮度曲线,并在高区间保留有限余量。 */
|
/** 标准 CSS 材质在壁纸归一化后只做窄幅目标亮度调整,避免重新放大原始明暗差异。 */
|
||||||
export function getGlassOpticalCssTransmissionBrightness(value: unknown) {
|
export function getGlassOpticalCssTransmissionBrightness(value: unknown) {
|
||||||
const transmission = getGlassOpticalTransmissionStrength(value)
|
const transmission = getGlassOpticalTransmissionStrength(value)
|
||||||
if (transmission <= 1) {
|
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
|
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) {
|
export function getGlassOpticalTransmissionStrength(value: unknown) {
|
||||||
const normalized = normalizeGlassOpticalStrength(value)
|
const normalized = normalizeGlassOpticalStrength(value)
|
||||||
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
|
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
|
||||||
|
|||||||
@@ -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,7 +1716,6 @@ function onDragStartPlugin(evt: any) {
|
|||||||
<VWindow v-model="activeTab" class="disable-tab-transition px-2" :touch="false">
|
<VWindow v-model="activeTab" class="disable-tab-transition px-2" :touch="false">
|
||||||
<!-- 我的插件 -->
|
<!-- 我的插件 -->
|
||||||
<VWindowItem value="installed">
|
<VWindowItem value="installed">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
@@ -1859,11 +1858,9 @@ function onDragStartPlugin(evt: any) {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
<!-- 插件市场 -->
|
<!-- 插件市场 -->
|
||||||
<VWindowItem value="market">
|
<VWindowItem value="market">
|
||||||
<transition name="fade-slide" appear>
|
|
||||||
<div>
|
<div>
|
||||||
<LoadingBanner
|
<LoadingBanner
|
||||||
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
|
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||||
@@ -1899,7 +1896,6 @@ function onDragStartPlugin(evt: any) {
|
|||||||
:error-description="t('plugin.allPluginsInstalled')"
|
:error-description="t('plugin.allPluginsInstalled')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user