fix(glass): stabilize optical rendering and page presentation (#595)

This commit is contained in:
InfinityPacer
2026-07-28 10:41:41 +08:00
committed by GitHub
parent 3daa031a04
commit 8a4ca2482c
22 changed files with 1641 additions and 449 deletions

View File

@@ -41,7 +41,13 @@ import {
getLoginVisualProfile,
prepareLoginBackgroundLayer,
settleLoginBackgroundLayers,
type LoginBackgroundLayer,
} from '@/utils/loginPresentation'
import {
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
loadGlassWallpaperToneProfile,
type GlassWallpaperToneProfile,
} from '@/utils/glassWallpaperTone'
const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
@@ -83,6 +89,7 @@ const globalSettingsStore = useGlobalSettingsStore()
// 背景图片
const backgroundImages = ref<string[]>([])
const backgroundLayers = ref(createLoginBackgroundLayers())
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false)
@@ -212,6 +219,31 @@ function handleTransparencySettingsChanged(event: Event) {
transparencyGlassQuality.value = glassQuality
}
/** 在壁纸可见前准备稳健曝光;不可读跨域图片回落中性 profile不阻断 CSS 背景。 */
async function ensureBackgroundToneProfile(imageUrl: string) {
if (!imageUrl || !isGlassTheme.value) return DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
const profile = await loadGlassWallpaperToneProfile(getOpticalBackgroundImage(imageUrl))
backgroundToneProfiles.value = {
...backgroundToneProfiles.value,
[imageUrl]: profile,
}
return profile
}
/** 让稳定双槽位分别携带当前壁纸的曝光,交叉淡化期间不共享新图参数。 */
function getBackgroundLayerStyle(layer: LoginBackgroundLayer) {
const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
const appearance = effectiveGlassSettings.value.glassAppearance
const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86
return {
'backgroundImage': layer.url ? `url(${layer.url})` : undefined,
'--glass-wallpaper-brightness': String(materialExposure * profile.exposure),
}
}
applyTransparentBackgroundSettings()
void router.isReady().then(() => {
@@ -501,13 +533,29 @@ function preloadNextBackgroundImage() {
/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */
async function preloadBackgroundCandidate(imageUrl: string) {
if (!shouldRenderGlassOpticalLayer.value) return preloadImage(imageUrl)
const toneProfile = isGlassTheme.value
? ensureBackgroundToneProfile(imageUrl)
: Promise.resolve(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
if (!shouldRenderGlassOpticalLayer.value) {
const [available] = await Promise.all([preloadImage(imageUrl), toneProfile])
return available
}
const opticalUrl = getOpticalBackgroundImage(imageUrl)
const opticalReady = await preloadCorsImage(opticalUrl)
if (!opticalReady) return preloadImage(imageUrl)
if (!opticalReady) {
const [displayReady] = await Promise.all([preloadImage(imageUrl), toneProfile])
return opticalUrl === imageUrl ? true : preloadImage(imageUrl)
return displayReady
}
const [displayReady] = await Promise.all([
opticalUrl === imageUrl ? Promise.resolve(true) : preloadImage(imageUrl),
toneProfile,
])
return displayReady
}
// 背景图片轮换函数
@@ -529,6 +577,7 @@ async function rotateBackgroundImage() {
preload: preloadImage,
})
if (!imagesReady) continue
await ensureBackgroundToneProfile(nextImage)
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
activateBackgroundImage(nextIndex)
@@ -811,6 +860,14 @@ onMounted(async () => {
{ immediate: true },
)
watch(isGlassTheme, enabled => {
if (!enabled) return
void Promise.all(
renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => ensureBackgroundToneProfile(layer.url)),
)
})
// 使用优化后的加载界面移除逻辑
ensureRenderComplete(() => {
nextTick(removeLoadingWithStateCheck)
@@ -883,7 +940,7 @@ onUnmounted(() => {
:key="layer.key"
class="background-image"
:class="layer.role"
:style="{ 'backgroundImage': layer.url ? `url(${layer.url})` : undefined }"
:style="getBackgroundLayerStyle(layer)"
/>
<!-- 全局磨砂层 -->
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
@@ -985,7 +1042,7 @@ onUnmounted(() => {
.background-container.is-glass-theme .background-image.active,
.background-container.is-glass-theme .background-image.previous {
filter: brightness(0.86) saturate(0.95) contrast(1.02);
filter: brightness(var(--glass-wallpaper-brightness, 0.86)) saturate(0.95) contrast(1.02);
}
.background-container.is-glass-theme .background-image.active {
@@ -1001,7 +1058,7 @@ onUnmounted(() => {
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active,
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous {
filter: brightness(0.85) saturate(0.97) contrast(1.02);
filter: brightness(var(--glass-wallpaper-brightness, 0.85)) saturate(0.97) contrast(1.02);
}
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active {
@@ -1017,7 +1074,7 @@ html[data-glass-appearance='tinted'] .background-container.is-glass-theme .backg
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active,
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous {
filter: brightness(0.82) saturate(0.9);
filter: brightness(var(--glass-wallpaper-brightness, 0.82)) saturate(0.9);
}
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active {

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import {
setGlassRendererState,
useGlassOpticalInteractionSource,
@@ -47,6 +48,7 @@ const emit = defineEmits<{
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
const interactionSource = useGlassOpticalInteractionSource()
const pagePresentationMotion = usePagePresentationMotion()
const fixedRenderer = useGlassOpticalRenderer({
active: true,
appearance: () => props.appearance,
@@ -76,6 +78,7 @@ const scrollRenderer = useGlassOpticalRenderer({
deformationStrength: () => props.deformationStrength,
flowStrength: () => props.flowStrength,
interactionSource,
pageMotion: pagePresentationMotion.reader,
quality: () => props.quality,
reflectionStrength: () => props.reflectionStrength,
transparencyStrength: () => props.transparencyStrength,

View File

@@ -53,6 +53,13 @@ describe('GlassOpticalLayer', () => {
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
expect(rendererCalls[0].pageMotion).toBeUndefined()
expect(rendererCalls[1].pageMotion).toEqual(
expect.objectContaining({
opacity: expect.any(Object),
revision: expect.any(Object),
}),
)
wrapper.unmount()
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')

View File

@@ -32,6 +32,9 @@ vi.mock('three', async importOriginal => {
clear() {}
dispose() {}
forceContextLoss() {}
getRenderTarget() {
return null
}
render() {}
setClearColor() {}
setPixelRatio() {}
@@ -347,6 +350,82 @@ describe('glass optical surface discovery', () => {
])
})
it('re-samples scroll surfaces and material weight on a shared page motion revision', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const root = document.createElement('div')
const canvas = document.createElement('canvas')
let presentationHeight = 800
Object.defineProperty(root, 'scrollHeight', {
configurable: true,
get: () => presentationHeight,
})
root.append(canvas)
document.body.append(root)
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const setSize = vi.spyOn(three.WebGLRenderer.prototype, 'setSize')
const bounds = { height: 240, width: 360, x: 80, y: 100 }
appendOpticalSurface('app-hover-lift-card', bounds)
const active = ref(true)
const opacity = ref(1)
const revision = ref(0)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
pageMotion: { active, opacity, revision },
quality: ref('balanced'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const initialScene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uRects: { value: Array<{ y: number }> }
uSurfaceWeights: { value: number[] }
}
}
}>
}
const initialY = initialScene.children[0].material.uniforms.uRects.value[0].y
render.mockClear()
setSize.mockClear()
bounds.y = 140
presentationHeight = 1200
opacity.value = 0.42
revision.value += 1
expect(setSize).toHaveBeenCalledOnce()
expect(setSize).toHaveBeenCalledWith(1200, 1200, false)
expect(render).toHaveBeenCalled()
const motionScene = render.mock.calls.at(-1)?.[0] as unknown as typeof initialScene
const uniforms = motionScene.children[0].material.uniforms
expect(uniforms.uRects.value[0].y).not.toBe(initialY)
expect(uniforms.uSurfaceWeights.value[0]).toBeCloseTo(0.42)
const observer = ResizeObserverMock.instances.find(instance => instance.targets.has(root))
expect(observer).toBeDefined()
setSize.mockClear()
render.mockClear()
presentationHeight = 1400
observer?.trigger()
expect(setSize).toHaveBeenCalledOnce()
expect(setSize).toHaveBeenCalledWith(1200, 1400, false)
expect(render).toHaveBeenCalled()
scope.stop()
})
it('recovers after consecutive WebGL context loss cycles', async () => {
const canvas = document.createElement('canvas')
const scope = effectScope()
@@ -376,6 +455,36 @@ describe('glass optical surface discovery', () => {
scope.stop()
})
it('keeps the renderer ready when the optional frosted prefilter fails', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
const compile = vi
.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
.mockRejectedValueOnce(new Error('prefilter unavailable'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('frosted'),
canvas: ref(canvas),
quality: ref('high'),
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
expect(compile).toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(
'玻璃磨砂壁纸预滤失败,继续使用实时扩散采样:',
expect.any(Error),
)
scope.stop()
})
it('releases renderer resources before restoring a single active instance', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
@@ -427,7 +536,7 @@ describe('glass optical surface discovery', () => {
await nextTick()
expect(rendererDispose).toHaveBeenCalledTimes(1)
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeFirstRelease + 2)
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeFirstRelease + 3)
expect(contextLoss).toHaveBeenCalledTimes(1)
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
@@ -460,7 +569,7 @@ describe('glass optical surface discovery', () => {
await nextTick()
expect(rendererDispose).toHaveBeenCalledTimes(2)
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeSecondRelease + 2)
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeSecondRelease + 3)
expect(contextLoss).toHaveBeenCalledTimes(2)
expect(resizeDisconnect).toHaveBeenCalledTimes(2)
expect(mutationDisconnect).toHaveBeenCalledTimes(2)
@@ -493,19 +602,17 @@ describe('glass optical surface discovery', () => {
quality.value = 'balanced'
await nextTick()
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
await vi.waitFor(() => expect(renderTargetDispose).toHaveBeenCalledTimes(4))
expect(rendererDispose).not.toHaveBeenCalled()
expect(contextLoss).not.toHaveBeenCalled()
expect(renderTargetDispose).toHaveBeenCalledTimes(2)
quality.value = 'high'
await nextTick()
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
await vi.waitFor(() => expect(renderTargetDispose).toHaveBeenCalledTimes(6))
expect(rendererDispose).not.toHaveBeenCalled()
expect(contextLoss).not.toHaveBeenCalled()
expect(renderTargetDispose).toHaveBeenCalledTimes(4)
scope.stop()
})
@@ -676,6 +783,9 @@ describe('glass optical surface discovery', () => {
material: {
fragmentShader: string
uniforms: {
uFrostedTexture: { value: unknown }
uHasFrostedTexture: { value: number }
uPreviousFrostedTexture: { value: unknown }
uPreviousTexture: { value: unknown }
uTexture: { value: unknown }
uTextureMix: { value: number }
@@ -685,11 +795,17 @@ describe('glass optical surface discovery', () => {
}
const uniforms = scene.children[0].material.uniforms
expect(uniforms.uPreviousTexture.value).not.toBe(uniforms.uTexture.value)
expect(uniforms.uPreviousFrostedTexture.value).not.toBe(uniforms.uFrostedTexture.value)
expect(uniforms.uHasFrostedTexture.value).toBe(1)
expect(uniforms.uTextureMix.value).toBeGreaterThanOrEqual(0)
expect(uniforms.uTextureMix.value).toBeLessThan(1)
expect(scene.children[0].material.fragmentShader).toContain(
'mix(toneMapWallpaper(previous, viewportUv), toneMapWallpaper(current, viewportUv), uTextureMix)',
'toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure)',
)
expect(scene.children[0].material.fragmentShader).toContain(
'toneMapWallpaper(current, viewportUv, uWallpaperExposure)',
)
expect(scene.children[0].material.fragmentShader).toContain('return mix(previousTone, currentTone, uTextureMix)')
expect(scene.children[0].material.fragmentShader).not.toContain(
'toneMapWallpaper(mix(previous, current, uTextureMix), viewportUv)',
)
@@ -734,7 +850,72 @@ describe('glass optical surface discovery', () => {
scope.stop()
})
it('resizes a scroll buffer when an asynchronous page grows without optical surfaces', async () => {
it('keeps the route first frame while transient scroll presentation heights settle', async () => {
vi.useFakeTimers()
const three = await import('three')
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
let presentationHeight = 844
const root = document.createElement('div')
const canvas = document.createElement('canvas')
Object.defineProperty(root, 'scrollHeight', {
configurable: true,
get: () => presentationHeight,
})
root.append(canvas)
document.body.append(root)
const routeKey = ref('/resource')
const setSize = vi.spyOn(three.WebGLRenderer.prototype, 'setSize')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('balanced'),
routeKey,
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const observer = ResizeObserverMock.instances.find(instance => instance.targets.has(root))
expect(observer).toBeDefined()
setSize.mockClear()
render.mockClear()
presentationHeight = 1744
routeKey.value = '/dashboard'
await nextTick()
await nextTick()
expect(setSize).toHaveBeenCalledOnce()
expect(setSize).toHaveBeenCalledWith(390, 1744, false)
setSize.mockClear()
presentationHeight = 2320
window.dispatchEvent(new Event('resize'))
expect(setSize).not.toHaveBeenCalled()
observer?.trigger()
presentationHeight = 2170
observer?.trigger()
presentationHeight = 1744
observer?.trigger()
expect(setSize).not.toHaveBeenCalled()
vi.advanceTimersByTime(159)
expect(setSize).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(setSize).not.toHaveBeenCalled()
scope.stop()
})
it('resizes a scroll buffer after an asynchronous page height becomes stable', async () => {
vi.useFakeTimers()
const three = await import('three')
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
@@ -771,12 +952,51 @@ describe('glass optical surface discovery', () => {
presentationHeight = 5000
observer?.trigger()
vi.advanceTimersByTime(159)
expect(setSize).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(setSize).toHaveBeenCalledOnce()
expect(setSize).toHaveBeenCalledWith(390, 3072, false)
expect(render).toHaveBeenCalled()
scope.stop()
})
it('updates surface geometry when the presentation root shares a resize delivery', async () => {
const three = await import('three')
const root = document.createElement('div')
const canvas = document.createElement('canvas')
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 320, x: 40, y: 80 })
root.append(canvas)
document.body.append(root)
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('balanced'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const observer = ResizeObserverMock.instances.find(
instance => instance.targets.has(root) && instance.targets.has(surface),
)
expect(observer).toBeDefined()
render.mockClear()
observer?.trigger()
expect(render).toHaveBeenCalled()
scope.stop()
})
it('writes static-material surfaces into the shader without dynamic optical energy', async () => {
const three = await import('three')
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 320, x: 40, y: 80 })
@@ -1557,13 +1777,43 @@ describe('glass optical surface discovery', () => {
expect(scene.children[0].material.fragmentShader).toContain('uniform float uReflectionStrength')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransparency')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransmissionStrength')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uPreviousWallpaperExposure')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uWallpaperExposure')
expect(scene.children[0].material.fragmentShader).toContain('compressWallpaperLuminance')
expect(scene.children[0].material.fragmentShader).toContain(
'toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure)',
)
expect(scene.children[0].material.fragmentShader).toContain(
'toneMapWallpaper(current, viewportUv, uWallpaperExposure)',
)
expect(scene.children[0].material.fragmentShader).toContain('float transmissionResponse')
expect(scene.children[0].material.fragmentShader).toContain('float referenceLiftProgress')
expect(scene.children[0].material.fragmentShader).toContain('float highTransmissionProgress')
expect(scene.children[0].material.fragmentShader).toContain('float frostedDensity')
expect(scene.children[0].material.fragmentShader).toContain(
'frosted * (1.0 - smoothstep(0.25, 0.9, uTransparency))',
)
expect(scene.children[0].material.fragmentShader).toContain(
'1.0 + frostedDensity * mix(1.15, 1.55, uQuality)',
)
expect(scene.children[0].material.fragmentShader).toContain(
'smoothstep(0.02, 0.55, liquidPresence)',
)
expect(scene.children[0].material.fragmentShader).toContain(
'mix(uTransparency * 0.26, 0.92, opticalCoverage)',
)
expect(scene.children[0].material.fragmentShader).toContain(
'mix(frostedBaseAlpha, 0.94, opticalCoverage)',
)
expect(scene.children[0].material.fragmentShader).toContain(
'mix(uTransparency * 0.44, 0.92, opticalCoverage)',
)
expect(scene.children[0].material.fragmentShader).toContain('vec3 transmissionReference')
expect(scene.children[0].material.fragmentShader).toContain('float shadowLiftGate')
expect(scene.children[0].material.fragmentShader).toContain('float shadowColorRetention')
expect(scene.children[0].material.fragmentShader).toContain('float referenceLift')
expect(scene.children[0].material.fragmentShader).toContain('float highlightProtection')
expect(scene.children[0].material.fragmentShader).toContain('protectedHighlightReference')
expect(scene.children[0].material.fragmentShader).toContain('mix(0.58, 0.92, uQuality)')
expect(scene.children[0].material.fragmentShader).toContain('float compressedLuminance')
expect(scene.children[0].material.fragmentShader).toContain('mix(0.58, 0.84, uQuality)')
expect(scene.children[0].material.fragmentShader).toContain('uMotion *')
expect(scene.children[0].material.fragmentShader).toContain('uTranslationStrength')
expect(scene.children[0].material.fragmentShader).toContain('vec2 lightDirection = normalize(vec2(-0.68, 0.74))')
@@ -1572,7 +1822,9 @@ describe('glass optical surface discovery', () => {
expect(scene.children[0].material.fragmentShader).toContain(
'causticHighlightMix * uReflectionStrength * highlightBudget',
)
expect(scene.children[0].material.fragmentShader).toContain('mix(0.78, 0.94, uTransparency)')
expect(scene.children[0].material.fragmentShader).not.toContain(
'materialAlpha = uTransparency * mix(',
)
expect(scene.children[0].material.fragmentShader).not.toContain('mix(0.035, 0.4')
expect(scene.children[0].material.fragmentShader).not.toContain('sin(')

View File

@@ -0,0 +1,163 @@
import {
getPagePresentationMotionProgress,
PAGE_PRESENTATION_MOTION_DURATION_MS,
PAGE_PRESENTATION_MOTION_START_OPACITY,
PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y,
usePagePresentationMotion,
} from '@/composables/usePagePresentationMotion'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const motion = usePagePresentationMotion()
let callbacks: Map<number, FrameRequestCallback>
let frameId: number
beforeEach(() => {
motion.cancel()
callbacks = new Map()
frameId = 0
document.documentElement.dataset.theme = 'glass'
vi.spyOn(performance, 'now').mockReturnValue(1000)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frameId += 1
callbacks.set(frameId, callback)
return frameId
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
callbacks.delete(id)
})
})
afterEach(() => {
motion.cancel()
delete document.documentElement.dataset.theme
delete document.documentElement.dataset.pagePresentationMotion
document.documentElement.style.removeProperty('--mp-page-motion-opacity')
document.documentElement.style.removeProperty('--mp-page-motion-translate-y')
})
describe('page presentation motion', () => {
it('holds a glass route until its shared layout geometry remains stable', () => {
const routeRoot = document.createElement('div')
let routeHeight = 2096
Object.defineProperties(routeRoot, {
offsetHeight: { configurable: true, get: () => routeHeight },
offsetWidth: { configurable: true, get: () => 1200 },
scrollHeight: { configurable: true, get: () => routeHeight },
scrollWidth: { configurable: true, get: () => 1200 },
})
document.body.append(routeRoot)
expect(motion.start('/dashboard', routeRoot)).toBe(true)
expect(motion.active.value).toBe(true)
expect(motion.opacity.value).toBe(0)
expect(document.documentElement.dataset.pagePresentationMotion).toBe('active')
;[1016, 1080].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp))
expect(motion.opacity.value).toBe(0)
routeHeight = 1520
;[1110, 1200].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp))
expect(motion.opacity.value).toBe(0)
;[1231].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp))
expect(motion.opacity.value).toBe(PAGE_PRESENTATION_MOTION_START_OPACITY)
expect(motion.translateY.value).toBe(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y)
routeRoot.remove()
})
it('uses one eased timeline for the initial, intermediate, and settled states', () => {
const initialRevision = motion.revision.value
expect(motion.start('/dashboard')).toBe(true)
expect(motion.active.value).toBe(true)
expect(motion.opacity.value).toBe(PAGE_PRESENTATION_MOTION_START_OPACITY)
expect(motion.translateY.value).toBe(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y)
expect(motion.revision.value).toBe(initialRevision + 1)
expect(document.documentElement.dataset.pagePresentationMotion).toBe('active')
const [firstFrame] = callbacks.values()
firstFrame(1000 + PAGE_PRESENTATION_MOTION_DURATION_MS / 2)
expect(motion.progress.value).toBeGreaterThan(0)
expect(motion.progress.value).toBeLessThan(1)
expect(motion.opacity.value).toBeGreaterThan(PAGE_PRESENTATION_MOTION_START_OPACITY)
expect(motion.opacity.value).toBeLessThan(1)
expect(motion.translateY.value).toBeGreaterThan(0)
expect(motion.translateY.value).toBeLessThan(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y)
const finalFrame = [...callbacks.values()].at(-1)!
finalFrame(1000 + PAGE_PRESENTATION_MOTION_DURATION_MS)
expect(motion.active.value).toBe(false)
expect(motion.progress.value).toBe(1)
expect(motion.opacity.value).toBe(1)
expect(motion.translateY.value).toBe(0)
expect(document.documentElement.dataset.pagePresentationMotion).toBeUndefined()
})
it('invalidates stale route frames without cancelling the current epoch', () => {
motion.start('/first')
const [staleFrame] = callbacks.values()
motion.start('/second')
const currentRevision = motion.revision.value
const currentFrame = [...callbacks.values()].at(-1)!
staleFrame(1016)
expect(motion.routeKey.value).toBe('/second')
expect(motion.revision.value).toBe(currentRevision)
expect([...callbacks.values()]).toContain(currentFrame)
currentFrame(1000 + PAGE_PRESENTATION_MOTION_DURATION_MS)
expect(motion.opacity.value).toBe(1)
})
it('publishes a final renderer revision when an active motion is cancelled', () => {
motion.start('/dashboard')
const [frame] = callbacks.values()
frame(1040)
const activeRevision = motion.revision.value
motion.cancel()
expect(motion.active.value).toBe(false)
expect(motion.opacity.value).toBe(1)
expect(motion.translateY.value).toBe(0)
expect(motion.revision.value).toBe(activeRevision + 1)
expect(document.documentElement.dataset.pagePresentationMotion).toBeUndefined()
})
it('settles immediately when reduced motion is requested', () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({
...window.matchMedia(''),
matches: true,
})
const initialRevision = motion.revision.value
expect(motion.start('/dashboard')).toBe(true)
expect(motion.active.value).toBe(false)
expect(motion.opacity.value).toBe(1)
expect(motion.revision.value).toBe(initialRevision + 1)
expect(callbacks.size).toBe(0)
})
it('leaves non-glass themes on the existing CSS animation path', () => {
document.documentElement.dataset.theme = 'dark'
expect(motion.start('/dashboard')).toBe(false)
expect(motion.active.value).toBe(false)
expect(callbacks.size).toBe(0)
})
it('keeps the shared cubic-bezier progress bounded and monotonic', () => {
const quarter = getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS * 0.25)
const half = getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS * 0.5)
const threeQuarters = getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS * 0.75)
expect(getPagePresentationMotionProgress(0)).toBe(0)
expect(quarter).toBeGreaterThan(0)
expect(half).toBeGreaterThan(quarter)
expect(threeQuarters).toBeGreaterThan(half)
expect(getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS)).toBe(1)
})
})

View File

@@ -47,7 +47,13 @@ import {
type GlassOpticalSurfaceSlot,
} from '@/utils/glassOptics'
import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer'
import type { PagePresentationMotionReader } from '@/composables/usePagePresentationMotion'
import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle'
import {
analyzeGlassWallpaperTone,
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
type GlassWallpaperToneProfile,
} from '@/utils/glassWallpaperTone'
type ThreeModule = typeof import('three')
export type GlassRendererState = 'fallback' | 'loading' | 'ready'
@@ -154,6 +160,7 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
uFlowStrength: IUniform<number>
uHasWallpaperTexture: IUniform<number>
uHasFlowTexture: IUniform<number>
uHasFrostedTexture: IUniform<number>
uMotion: IUniform<number>
uMotionExpansion: IUniform<number>
uMaxRefractionPixels: IUniform<number>
@@ -161,6 +168,7 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
uPointerVelocity: IUniform<Vector2>
uPresentationSize: IUniform<Vector2>
uPreviousCoverScale: IUniform<Vector2>
uPreviousWallpaperExposure: IUniform<number>
uQuality: IUniform<number>
uReflectionStrength: IUniform<number>
uRadii: IUniform<Vector4[]>
@@ -169,8 +177,11 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
uSurfaceWeights: IUniform<number[]>
uSurfaceDynamics: IUniform<number[]>
uPreviousTexture: IUniform<Texture | null>
uPreviousFrostedTexture: IUniform<Texture | null>
uTexture: IUniform<Texture | null>
uFrostedTexture: IUniform<Texture | null>
uTextureMix: IUniform<number>
uWallpaperExposure: IUniform<number>
uTintColor: IUniform<Color>
uTransparency: IUniform<number>
uTransmissionStrength: IUniform<number>
@@ -212,6 +223,17 @@ interface GlassFlowResources {
writeTarget: WebGLRenderTarget
}
interface GlassFrostPrefilterResources {
material: ShaderMaterial
mesh: Mesh
scene: Scene
uniforms: {
uDirection: IUniform<Vector2>
uTexture: IUniform<Texture | null>
uTextureSize: IUniform<Vector2>
}
}
interface UseGlassOpticalRendererOptions {
active: MaybeRefOrGetter<boolean>
appearance: MaybeRefOrGetter<ThemeCustomizerGlassAppearance>
@@ -221,6 +243,8 @@ interface UseGlassOpticalRendererOptions {
interactionSource?: GlassOpticalInteractionSource
/** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */
motionStrength?: MaybeRefOrGetter<number>
/** scroll-space 页面表面与 DOM 共用的短时呈现状态fixed-space 不参与路由入场。 */
pageMotion?: PagePresentationMotionReader
quality: MaybeRefOrGetter<GlassOpticalQuality>
reflectionStrength?: MaybeRefOrGetter<number>
previousWallpaperUrl?: MaybeRefOrGetter<string>
@@ -244,12 +268,16 @@ type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement> &
}
interface PreparedWallpaperTexture {
/** 单次预滤后的低分辨率壁纸;中间 RenderTarget 不跨准备阶段保留。 */
frostedTarget: WebGLRenderTarget | null
/** 是否包含可采样的真实壁纸,而非程序化回退纹理。 */
hasWallpaperTexture: boolean
/** 纹理像素高度。 */
height: number
/** 已完成当前 WebGL context 上传的纹理。 */
texture: Texture
/** 纹理自身的稳健整体曝光;双纹理过渡期间不可与另一张壁纸共用。 */
toneProfile: GlassWallpaperToneProfile
/** 纹理像素宽度。 */
width: number
}
@@ -344,11 +372,33 @@ void main() {
}
`
const FROST_PREFILTER_FRAGMENT_SHADER = `
precision highp float;
uniform sampler2D uTexture;
uniform vec2 uDirection;
uniform vec2 uTextureSize;
varying vec2 vUv;
void main() {
vec2 texel = uDirection / max(uTextureSize, vec2(1.0));
vec3 color =
texture2D(uTexture, vUv).rgb * 0.227027 +
texture2D(uTexture, vUv + texel * 1.384615).rgb * 0.316216 +
texture2D(uTexture, vUv - texel * 1.384615).rgb * 0.316216 +
texture2D(uTexture, vUv + texel * 3.230769).rgb * 0.070270 +
texture2D(uTexture, vUv - texel * 3.230769).rgb * 0.070270;
gl_FragColor = vec4(color, 1.0);
}
`
const FRAGMENT_SHADER = `
precision highp float;
uniform sampler2D uPreviousTexture;
uniform sampler2D uTexture;
uniform sampler2D uPreviousFrostedTexture;
uniform sampler2D uFrostedTexture;
uniform sampler2D uFlowTexture;
uniform vec2 uCoverScale;
uniform vec2 uPreviousCoverScale;
@@ -359,6 +409,7 @@ uniform float uDeformationStrength;
uniform float uFlowStrength;
uniform float uHasWallpaperTexture;
uniform float uHasFlowTexture;
uniform float uHasFrostedTexture;
uniform float uMotion;
uniform float uMotionExpansion;
uniform float uMaxRefractionPixels;
@@ -379,6 +430,8 @@ uniform float uTransparency;
uniform float uTransmissionStrength;
uniform float uTranslationStrength;
uniform float uTextureMix;
uniform float uPreviousWallpaperExposure;
uniform float uWallpaperExposure;
uniform vec4 uTrail[4];
uniform int uTrailCount;
varying vec2 vUv;
@@ -409,16 +462,30 @@ vec2 coverUv(vec2 uv) {
return vec2(0.5) + (viewportUv - vec2(0.5)) * uCoverScale;
}
vec3 toneMapWallpaper(vec3 color, vec2 uv) {
vec3 compressWallpaperLuminance(vec3 color, float wallpaperExposure) {
vec3 exposed = max(color * wallpaperExposure, vec3(0.0));
float sourceLuminance = dot(exposed, vec3(0.2126, 0.7152, 0.0722));
if (sourceLuminance <= 0.0001) return exposed;
float shadowLift = (1.0 - smoothstep(0.06, 0.5, sourceLuminance)) * 0.22;
float highlightCompression = smoothstep(0.72, 0.98, sourceLuminance) * 0.08;
float compressedLuminance =
sourceLuminance * (1.0 + shadowLift) * (1.0 - highlightCompression);
return clamp(exposed * (compressedLuminance / sourceLuminance), 0.0, 1.0);
}
vec3 toneMapWallpaper(vec3 color, vec2 uv, float wallpaperExposure) {
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
float frosted = step(1.5, uAppearance);
float exposure = mix(0.86, 0.85, tinted);
exposure = mix(exposure, 0.82, frosted);
float saturation = mix(0.95, 0.97, tinted);
float saturation = mix(0.82, 0.95, tinted);
saturation = mix(saturation, 0.9, frosted);
float contrast = mix(1.02, 1.0, frosted);
float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
vec3 mapped = mix(vec3(luminance), color, saturation);
vec3 normalized = compressWallpaperLuminance(color, wallpaperExposure);
float luminance = dot(normalized, vec3(0.2126, 0.7152, 0.0722));
vec3 mapped = mix(vec3(luminance), normalized, saturation);
mapped = clamp((mapped - vec3(0.5)) * contrast + vec3(0.5), 0.0, 1.0) * exposure;
float top = 1.0 - uv.y;
@@ -429,54 +496,63 @@ vec3 toneMapWallpaper(vec3 color, vec2 uv) {
float radialAbsorption = smoothstep(0.24, 0.92, length(radialDelta)) * mix(0.12, 0.14, tinted);
radialAbsorption *= 1.0 - frosted;
vec3 absorbed = mapped * (1.0 - linearAbsorption) * (1.0 - radialAbsorption);
float transmissionExpansion = max(0.0, (uTransmissionStrength - 0.5) * 2.0);
float transmissionResponse = pow(transmissionExpansion, mix(0.8, 0.65, uQuality));
float transmissionMaterialScale = mix(1.0, 0.78, tinted);
float transmissionResponse =
pow(clamp(uTransmissionStrength, 0.0, 1.0), mix(0.9, 0.78, uQuality));
float referenceLiftProgress = smoothstep(0.7, 1.0, uTransmissionStrength);
float highTransmissionProgress =
clamp((uTransmissionStrength - 1.0) / 0.3, 0.0, 1.0);
float transmissionMaterialScale = mix(1.0, 0.9, tinted);
transmissionMaterialScale = mix(transmissionMaterialScale, 0.42, frosted);
float shadowGamma =
1.0 -
transmissionResponse *
transmissionMaterialScale *
mix(0.28, 0.5, uQuality);
float shadowLiftGate = smoothstep(0.035, 0.2, luminance);
float highlightProtection = smoothstep(0.72, 0.96, luminance);
float protectedGamma = mix(max(shadowGamma, 0.72), shadowGamma, shadowLiftGate);
protectedGamma = mix(protectedGamma, 1.0, highlightProtection);
vec3 expandedSource = pow(max(color, vec3(0.0)), vec3(protectedGamma));
float sourceLuminance = dot(expandedSource, vec3(0.2126, 0.7152, 0.0722));
float shadowColorRetention = mix(0.42, 1.0, smoothstep(0.025, 0.18, luminance));
expandedSource = mix(vec3(sourceLuminance), expandedSource, shadowColorRetention);
sourceLuminance = dot(expandedSource, vec3(0.2126, 0.7152, 0.0722));
vec3 transmissionReference = mix(vec3(sourceLuminance), expandedSource, mix(1.16, 1.08, frosted));
transmissionReference =
clamp((transmissionReference - vec3(0.5)) * 1.06 + vec3(0.5), 0.0, 1.0) *
1.04;
vec3 protectedHighlightReference = min(color * 1.02, vec3(1.0));
float highlightProtection = smoothstep(0.68, 0.92, luminance);
vec3 transmissionReference = normalized;
float referenceLift =
transmissionMaterialScale *
(
referenceLiftProgress * mix(0.18, 0.25, uQuality) +
highTransmissionProgress * mix(0.1, 0.16, uQuality)
);
transmissionReference *= 1.0 + referenceLift * (1.0 - highlightProtection * 0.72);
transmissionReference = min(transmissionReference, vec3(0.96));
vec3 protectedHighlightReference = min(normalized, vec3(0.96));
transmissionReference = mix(
transmissionReference,
protectedHighlightReference,
highlightProtection * 0.72
highlightProtection * 0.78
);
float transmissionMix = min(
transmissionResponse *
transmissionMaterialScale *
mix(0.58, 0.92, uQuality),
0.94
mix(0.58, 0.84, uQuality),
0.84
);
return mix(absorbed, min(transmissionReference, vec3(1.0)), transmissionMix);
return mix(absorbed, transmissionReference, transmissionMix);
}
vec3 sampleWallpaper(vec2 uv) {
vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001));
vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale;
vec3 previous = texture2D(uPreviousTexture, previousUv).rgb;
vec3 current = texture2D(uTexture, uv).rgb;
vec3 previous;
vec3 current;
if (uAppearance > 1.5 && uHasFrostedTexture > 0.5) {
previous = texture2D(uPreviousFrostedTexture, previousUv).rgb;
current = texture2D(uFrostedTexture, uv).rgb;
} else {
previous = texture2D(uPreviousTexture, previousUv).rgb;
current = texture2D(uTexture, uv).rgb;
}
if (uTextureMix <= 0.001) return toneMapWallpaper(previous, viewportUv);
if (uTextureMix >= 0.999) return toneMapWallpaper(current, viewportUv);
if (uTextureMix <= 0.001) {
return toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure);
}
if (uTextureMix >= 0.999) {
return toneMapWallpaper(current, viewportUv, uWallpaperExposure);
}
return mix(toneMapWallpaper(previous, viewportUv), toneMapWallpaper(current, viewportUv), uTextureMix);
vec3 previousTone = toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure);
vec3 currentTone = toneMapWallpaper(current, viewportUv, uWallpaperExposure);
return mix(previousTone, currentTone, uTextureMix);
}
vec3 sampleChromatic(vec2 uv, float separation) {
@@ -520,7 +596,7 @@ vec3 sampleHighQualityDiffuse(vec2 uv, vec2 axis, float radius) {
}
float getContentProtection(vec2 sourceUv) {
if (uQuality < 0.5 || uHasWallpaperTexture < 0.5) return 1.0;
if (uQuality < 0.5 || uHasWallpaperTexture < 0.5 || uAppearance > 1.5) return 1.0;
vec2 sourceTexel = max(uCoverScale, vec2(0.0001)) / max(uVisibleViewportSize, vec2(1.0));
vec3 horizontalStart = sampleWallpaper(sourceUv - vec2(sourceTexel.x * 2.5, 0.0));
@@ -661,7 +737,7 @@ void main() {
pointerEnergy,
max(min(1.0, trailEnergy) * 0.68, max(temporalEnergy * 0.76, wakeEnergy * 0.82))
), 0.0, 1.0);
float staticLens = 0.00018 + edgeResponse * mix(0.0011, 0.0017, uQuality);
float staticLens = 0.00008 + edgeResponse * mix(0.00045, 0.00072, uQuality);
float pointerStrength = mix(mix(0.0055, 0.008, uQuality), mix(0.0085, 0.012, uQuality), frosted);
float trailStrength = mix(mix(0.78, 1.08, uQuality), mix(0.96, 1.3, uQuality), frosted);
float temporalStrength = mix(0.032, 0.042, frosted) * uQuality * (1.0 + flowSurfaceDetail * 0.5);
@@ -707,11 +783,16 @@ void main() {
dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction);
vec2 refraction = staticRefraction + dynamicRefraction;
vec2 sourceUv = coverUv(vUv + refraction);
float separation = edge * mix(0.00072, 0.0013, uQuality) * mix(1.0, 0.58, frosted);
vec3 refracted = sampleChromatic(sourceUv, separation);
float separation = edge * mix(0.00024, 0.00055, uQuality) * mix(1.0, 0.58, frosted);
float usesPrefilteredFrost = frosted * uHasFrostedTexture;
vec3 refracted = usesPrefilteredFrost > 0.5
? sampleWallpaper(sourceUv)
: sampleChromatic(sourceUv, separation);
float detailSeparation = separation * mix(1.45, 2.35, uQuality);
vec3 detailed = sampleChromatic(sourceUv, detailSeparation);
refracted = mix(refracted, detailed, mix(0.12, 0.32, uQuality) * (1.0 - frosted));
vec3 detailed = usesPrefilteredFrost > 0.5
? refracted
: sampleChromatic(sourceUv, detailSeparation);
refracted = mix(refracted, detailed, mix(0.06, 0.16, uQuality) * (1.0 - frosted));
vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular;
float diffusionRadius =
mix(0.0022, 0.0038, uQuality) *
@@ -720,8 +801,12 @@ void main() {
materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
flowSurfaceDetail * dynamicMask * 0.38
);
float frostedDensity = frosted * (1.0 - smoothstep(0.25, 0.9, uTransparency));
diffusionRadius *= 1.0 + frostedDensity * mix(1.15, 1.55, uQuality);
vec3 diffused;
if (uQuality > 0.5) {
if (usesPrefilteredFrost > 0.5) {
diffused = refracted;
} else if (uQuality > 0.5) {
diffused = sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius);
} else {
diffused = sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
@@ -729,9 +814,9 @@ void main() {
refracted = mix(refracted, diffused, frosted);
float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
float transmissionOffset = (uTransmissionStrength - 0.5) * 2.0;
float transmissionOffset = min(uTransmissionStrength - 1.0, 0.0);
if (transmissionOffset < 0.0) {
float dimming = mix(0.18, 0.22, uQuality) * mix(1.0, 0.65, frosted);
float dimming = mix(0.14, 0.18, uQuality) * mix(1.0, 0.65, frosted);
refracted *= 1.0 + transmissionOffset * dimming;
}
refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
@@ -748,7 +833,9 @@ void main() {
float edgeHighlightMix = 0.12;
float causticHighlightMix = 0.075;
float liquidPresence = clamp(materialEnergy, 0.0, 1.0);
float materialAlpha = uTransparency * mix(0.84, 1.0, liquidPresence);
// 只有局部动态覆盖可以提高壁纸替换权,静止像素继续遵循用户设置的通透度。
float opticalCoverage = smoothstep(0.02, 0.55, liquidPresence);
float materialAlpha = mix(uTransparency * 0.26, 0.92, opticalCoverage);
float proceduralEdgeAlpha = 0.14;
float proceduralCausticAlpha = 0.075;
@@ -756,14 +843,15 @@ void main() {
highlight = vec3(0.94, 0.97, 1.0);
edgeHighlightMix = 0.15;
causticHighlightMix = 0.042;
materialAlpha = mix(0.78, 0.94, uTransparency) * mix(0.9, 1.0, liquidPresence);
float frostedBaseAlpha = mix(0.78, 0.94, uTransparency) * 0.9;
materialAlpha = mix(frostedBaseAlpha, 0.94, opticalCoverage);
proceduralEdgeAlpha = 0.16;
proceduralCausticAlpha = 0.045;
} else if (uAppearance > 0.5) {
highlight = mix(vec3(1.0), uTintColor, 0.72);
edgeHighlightMix = 0.17;
causticHighlightMix = 0.085;
materialAlpha = uTransparency * mix(0.84, 1.0, liquidPresence);
materialAlpha = mix(uTransparency * 0.44, 0.92, opticalCoverage);
}
if (uHasWallpaperTexture < 0.5) {
@@ -819,9 +907,12 @@ void main() {
`
const SCROLL_STABLE_TAIL_FRAMES = 2
const PRESENTATION_RESIZE_SAMPLE_MS = 80
const PRESENTATION_RESIZE_REQUIRED_SAMPLES = 2
const SURFACE_STABILITY_MAX_FRAMES = 6
const SURFACE_STABILITY_REQUIRED_FRAMES = 2
const FLOW_BUFFER_SCALE = 0.25
const FROST_PREFILTER_SCALE = 0.125
const SURFACE_TRANSITION_DURATION_MS = 96
const SURFACE_TRANSFORM_TRACKING_MAX_MS = 1000
@@ -965,12 +1056,17 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let three: ThreeModule | null = null
let resources: GlassRendererResources | null = null
let flowResources: GlassFlowResources | null = null
let frostPrefilterResources: GlassFrostPrefilterResources | null = null
let activeTexture: Texture | null = null
let activeFrostedTarget: WebGLRenderTarget | null = null
let activeTextureHeight = 1
let activeTextureWidth = 1
let activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
let previousTexture: Texture | null = null
let previousFrostedTarget: WebGLRenderTarget | null = null
let previousTextureHeight = 1
let previousTextureWidth = 1
let previousWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
let loadVersion = 0
let prepareVersion = 0
let preparedWallpaper: PreparedWallpaperTexture | null = null
@@ -999,6 +1095,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let activeTouchIdentifier: number | null = null
let pendingFlowInjection = 0
let interactionAnimating = false
let presentationResizeCandidate = ''
let presentationResizeStableSamples = 0
let presentationResizeTimer: number | null = null
let scrollAnimationFrame: number | null = null
let scrollDirty = false
let scrollSurfaceRefreshPending = false
@@ -1017,6 +1116,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let surfaceTransformFrame: number | null = null
let surfaceTransformTrackingDeadline = 0
const transformingSurfaces = new Set<HTMLElement>()
let pagePresentationGeometryReady = true
let wakeDirection = { x: 0, y: -1 }
let contextRecoveryPending = false
const presentationSpace = options.surfaceSpace ?? 'fixed'
@@ -1095,16 +1195,28 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
backgroundDisposeTimer = null
}
/** 源纹理与磨砂预滤目标共享同一壁纸生命周期,必须成对释放。 */
function disposeWallpaperResources(texture: Texture | null, frostedTarget: WebGLRenderTarget | null) {
texture?.dispose()
frostedTarget?.dispose()
}
/** 释放已完成过渡的旧纹理,并让两个采样槽继续指向同一稳定壁纸。 */
function finishWallpaperTransition() {
if (!resources || !activeTexture) return
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
if (previousTexture && previousTexture !== activeTexture) {
disposeWallpaperResources(previousTexture, previousFrostedTarget)
}
previousTexture = null
previousFrostedTarget = null
previousTextureHeight = activeTextureHeight
previousTextureWidth = activeTextureWidth
previousWallpaperExposure = activeWallpaperExposure
resources.uniforms.uPreviousTexture.value = activeTexture
resources.uniforms.uPreviousFrostedTexture.value = activeFrostedTarget?.texture ?? activeTexture
resources.uniforms.uPreviousCoverScale.value.copy(resources.uniforms.uCoverScale.value)
resources.uniforms.uPreviousWallpaperExposure.value = activeWallpaperExposure
resources.uniforms.uTextureMix.value = 1
cancelWallpaperTransitionFrame()
}
@@ -1141,6 +1253,91 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
}
/** 释放壁纸准备阶段复用的预滤 shader活动低通纹理由各自 RenderTarget 单独持有。 */
function disposeFrostPrefilterResources() {
if (!frostPrefilterResources) return
frostPrefilterResources.material.dispose()
frostPrefilterResources = null
}
/** 为当前 WebGL context 创建一次性低分辨率壁纸预滤管线。 */
function getFrostPrefilterResources() {
if (frostPrefilterResources) return frostPrefilterResources
if (!resources || !three) return null
const uniforms = {
uDirection: { value: new three.Vector2(1, 0) },
uTexture: { value: null },
uTextureSize: { value: new three.Vector2(1, 1) },
}
const material = new three.ShaderMaterial({
depthTest: false,
depthWrite: false,
fragmentShader: FROST_PREFILTER_FRAGMENT_SHADER,
uniforms,
vertexShader: VERTEX_SHADER,
})
const scene = new three.Scene()
const mesh = new three.Mesh(resources.geometry, material)
mesh.frustumCulled = false
scene.add(mesh)
frostPrefilterResources = { material, mesh, scene, uniforms }
return frostPrefilterResources
}
/** 壁纸上传时执行两次 separable blur常态只保留最终 1/8 RenderTarget。 */
async function createFrostedWallpaperTarget(texture: Texture, width: number, height: number) {
if (!resources || !three) return null
const ownerResources = resources
const prefilter = getFrostPrefilterResources()
if (!prefilter) return null
const targetWidth = Math.max(1, Math.round(width * FROST_PREFILTER_SCALE))
const targetHeight = Math.max(1, Math.round(height * FROST_PREFILTER_SCALE))
const createTarget = () =>
new three!.WebGLRenderTarget(targetWidth, targetHeight, {
depthBuffer: false,
magFilter: three!.LinearFilter,
minFilter: three!.LinearFilter,
stencilBuffer: false,
})
const intermediateTarget = createTarget()
const outputTarget = createTarget()
const previousTarget = ownerResources.renderer.getRenderTarget()
try {
ownerResources.renderer.initTexture(texture)
await ownerResources.renderer.compileAsync(prefilter.scene, ownerResources.camera)
if (resources !== ownerResources) {
outputTarget.dispose()
return null
}
prefilter.uniforms.uDirection.value.set(1, 0)
prefilter.uniforms.uTexture.value = texture
prefilter.uniforms.uTextureSize.value.set(width, height)
ownerResources.renderer.setRenderTarget(intermediateTarget)
ownerResources.renderer.render(prefilter.scene, ownerResources.camera)
prefilter.uniforms.uDirection.value.set(0, 1)
prefilter.uniforms.uTexture.value = intermediateTarget.texture
prefilter.uniforms.uTextureSize.value.set(targetWidth, targetHeight)
ownerResources.renderer.setRenderTarget(outputTarget)
ownerResources.renderer.render(prefilter.scene, ownerResources.camera)
return outputTarget
} catch (error) {
outputTarget.dispose()
throw error
} finally {
if (resources === ownerResources) ownerResources.renderer.setRenderTarget(previousTarget)
intermediateTarget.dispose()
}
}
/** 释放仅由高质量档使用的短时液态位移场。 */
function disposeFlowResources() {
if (!flowResources) return
@@ -1270,6 +1467,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const transitionWeights = outgoingSurface
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
: { incoming: 1, outgoing: 0 }
const pageMotionOpacity =
presentationSpace === 'scroll' ? Math.min(1, Math.max(0, toValue(options.pageMotion?.opacity ?? 1))) : 1
const pagePresentationWeight = pagePresentationGeometryReady ? pageMotionOpacity : 0
for (let index = 0; index < 8; index += 1) {
const surface = normalized[index]
@@ -1278,7 +1478,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const radii = surface?.radii ?? [0, 0, 0, 0]
uniformRects[index].set(rect[0], rect[1], rect[2], rect[3])
uniformRadii[index].set(radii[0], radii[1], radii[2], radii[3])
uniformWeights[index] =
const surfaceWeight =
slot?.role === 'outgoing'
? transitionWeights.outgoing
: slot?.role === 'active'
@@ -1286,6 +1486,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
: slot
? 1
: 0
uniformWeights[index] = surfaceWeight * pagePresentationWeight
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
}
@@ -1373,21 +1574,76 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
surfaceStabilityPass < SURFACE_STABILITY_MAX_FRAMES
) {
surfaceStabilityFrame = requestAnimationFrame(sample)
} else if (!pagePresentationGeometryReady) {
pagePresentationGeometryReady = true
writeSurfaceUniforms(timestamp)
renderFrame(timestamp, false)
}
}
surfaceStabilityFrame = requestAnimationFrame(sample)
}
/** ResizeObserver 在浏览器绘制前提交新尺寸,避免 CSS 与 WebGL buffer 跨帧失配。 */
/**
* 页面根的 scrollHeight 可能在 content box 稳定后继续收敛。
* 连续两个 80ms 样本一致才允许覆盖已提交的 presentation 首帧。
*/
function schedulePresentationResizeUpdate() {
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
presentationResizeCandidate = ''
presentationResizeStableSamples = 0
const sample = () => {
presentationResizeTimer = null
if (!resources) return
const presentation = getPresentationSize()
const candidate = `${window.innerWidth},${window.innerHeight},${presentation.width},${presentation.height}`
presentationResizeStableSamples =
candidate === presentationResizeCandidate ? presentationResizeStableSamples + 1 : 1
presentationResizeCandidate = candidate
if (presentationResizeStableSamples >= PRESENTATION_RESIZE_REQUIRED_SAMPLES) {
resizeRenderer()
presentationResizeCandidate = ''
presentationResizeStableSamples = 0
return
}
presentationResizeTimer = window.setTimeout(sample, PRESENTATION_RESIZE_SAMPLE_MS)
}
presentationResizeTimer = window.setTimeout(sample, PRESENTATION_RESIZE_SAMPLE_MS)
}
/** 共享页面 motion 活跃时,页面几何变化必须在浏览器绘制前完成一次完整 presentation 提交。 */
function commitActivePagePresentation(timestamp = performance.now()) {
if (
!resources ||
presentationSpace !== 'scroll' ||
!toValue(options.pageMotion?.active ?? false)
)
return false
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
presentationResizeTimer = null
presentationResizeCandidate = ''
presentationResizeStableSamples = 0
resizeRenderer()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
return true
}
/** 普通表面尺寸即时更新;页面根尺寸在稳定后覆盖 presentation。 */
function handleSurfaceResize(entries: ResizeObserverEntry[]) {
if (!resources) return
const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
if (presentationRoot && entries.some(entry => entry.target === presentationRoot)) {
resizeRenderer()
return
}
const presentationChanged = presentationRoot && entries.some(entry => entry.target === presentationRoot)
if (presentationChanged && commitActivePagePresentation()) return
if (presentationChanged) schedulePresentationResizeUpdate()
if (!entries.some(entry => entry.target !== presentationRoot)) return
const timestamp = performance.now()
updateSurfaceUniforms(timestamp, false)
@@ -2054,6 +2310,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
void initializeRenderer(false)
}
function handleWindowResize() {
if (presentationSpace === 'scroll') schedulePresentationResizeUpdate()
else resizeRenderer()
}
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
return mutations.some(
@@ -2083,7 +2344,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
surfaceMutationObserver = new MutationObserver(mutations => {
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
if (mutationTouchesOpticalSurface(mutations)) scheduleSurfaceStabilityUpdate()
if (!mutationTouchesOpticalSurface(mutations)) return
commitActivePagePresentation()
scheduleSurfaceStabilityUpdate()
})
observeMutationRoot(document.querySelector('.app-wrapper'), true)
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
@@ -2104,7 +2368,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
window.addEventListener('touchend', handleTouchEnd, { passive: true })
window.addEventListener('touchcancel', handleTouchEnd, { passive: true })
}
window.addEventListener('resize', resizeRenderer, { passive: true })
window.addEventListener('resize', handleWindowResize, { passive: true })
window.addEventListener('transitionrun', handleSurfaceTransitionRun, { capture: true, passive: true })
window.addEventListener('transitionend', handleSurfaceTransitionEnd, { capture: true, passive: true })
window.addEventListener('transitioncancel', handleSurfaceTransitionEnd, { capture: true, passive: true })
@@ -2126,7 +2390,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
window.removeEventListener('touchend', handleTouchEnd)
window.removeEventListener('touchcancel', handleTouchEnd)
}
window.removeEventListener('resize', resizeRenderer)
window.removeEventListener('resize', handleWindowResize)
window.removeEventListener('transitionrun', handleSurfaceTransitionRun, true)
window.removeEventListener('transitionend', handleSurfaceTransitionEnd, true)
window.removeEventListener('transitioncancel', handleSurfaceTransitionEnd, true)
@@ -2156,6 +2420,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
cancelAnimationFrame(surfaceStabilityFrame)
surfaceStabilityFrame = null
}
if (presentationResizeTimer !== null) {
window.clearTimeout(presentationResizeTimer)
presentationResizeTimer = null
}
presentationResizeCandidate = ''
presentationResizeStableSamples = 0
removeEvents()
resizeObserver?.disconnect()
resizeObserver = null
@@ -2180,19 +2450,28 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
pointerSpringVelocityY = 0
pendingFlowInjection = 0
lastInteractionFrameAt = 0
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
if (previousTexture && previousTexture !== activeTexture) {
disposeWallpaperResources(previousTexture, previousFrostedTarget)
}
previousTexture = null
previousFrostedTarget = null
previousTextureHeight = 1
previousTextureWidth = 1
activeTexture?.dispose()
previousWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
disposeWallpaperResources(activeTexture, activeFrostedTarget)
activeTexture = null
activeFrostedTarget = null
activeTextureHeight = 1
activeTextureWidth = 1
preparedWallpaper?.texture.dispose()
activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
if (preparedWallpaper) {
disposeWallpaperResources(preparedWallpaper.texture, preparedWallpaper.frostedTarget)
}
preparedWallpaper = null
preparedWallpaperUrl.value = ''
disposeFlowResources()
disposeFrostPrefilterResources()
if (resources) {
resources.geometry.dispose()
resources.material.dispose()
@@ -2237,9 +2516,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}
/** 在同一 renderer 内交接新旧纹理;只有真实壁纸轮换才启用双纹理时钟。 */
function activateLoadedTexture(texture: Texture, width: number, height: number, hasWallpaperTexture: boolean) {
function activateLoadedTexture(
texture: Texture,
frostedTarget: WebGLRenderTarget | null,
width: number,
height: number,
hasWallpaperTexture: boolean,
toneProfile: GlassWallpaperToneProfile,
) {
if (!resources) {
texture.dispose()
disposeWallpaperResources(texture, frostedTarget)
return
}
@@ -2250,15 +2536,25 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
cancelWallpaperTransitionFrame()
if (hasActiveTransition && activeTexture) {
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
if (previousTexture && previousTexture !== activeTexture) {
disposeWallpaperResources(previousTexture, previousFrostedTarget)
}
previousTexture = activeTexture
previousFrostedTarget = activeFrostedTarget
previousTextureHeight = activeTextureHeight
previousTextureWidth = activeTextureWidth
previousWallpaperExposure = activeWallpaperExposure
activeTexture = texture
activeFrostedTarget = frostedTarget
activeTextureHeight = height
activeTextureWidth = width
activeWallpaperExposure = toneProfile.exposure
resources.uniforms.uPreviousTexture.value = previousTexture
resources.uniforms.uPreviousFrostedTexture.value = previousFrostedTarget?.texture ?? previousTexture
resources.uniforms.uTexture.value = activeTexture
resources.uniforms.uFrostedTexture.value = activeFrostedTarget?.texture ?? activeTexture
resources.uniforms.uPreviousWallpaperExposure.value = previousWallpaperExposure
resources.uniforms.uWallpaperExposure.value = activeWallpaperExposure
resources.uniforms.uTextureMix.value = getGlassWallpaperTransitionProgress(
performance.now() - toValue(options.transitionStartedAt ?? 0),
toValue(options.transitionDuration ?? 0),
@@ -2266,21 +2562,32 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
syncCoverScale()
scheduleWallpaperTransition()
} else {
if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose()
activeTexture?.dispose()
if (previousTexture && previousTexture !== activeTexture) {
disposeWallpaperResources(previousTexture, previousFrostedTarget)
}
disposeWallpaperResources(activeTexture, activeFrostedTarget)
previousTexture = null
previousFrostedTarget = null
activeTexture = texture
activeFrostedTarget = frostedTarget
activeTextureHeight = height
activeTextureWidth = width
activeWallpaperExposure = toneProfile.exposure
previousTextureHeight = height
previousTextureWidth = width
previousWallpaperExposure = toneProfile.exposure
resources.uniforms.uPreviousTexture.value = texture
resources.uniforms.uPreviousFrostedTexture.value = frostedTarget?.texture ?? texture
resources.uniforms.uTexture.value = texture
resources.uniforms.uFrostedTexture.value = frostedTarget?.texture ?? texture
resources.uniforms.uPreviousWallpaperExposure.value = toneProfile.exposure
resources.uniforms.uWallpaperExposure.value = toneProfile.exposure
resources.uniforms.uTextureMix.value = 1
syncCoverScale()
}
resources.uniforms.uHasWallpaperTexture.value = hasWallpaperTexture ? 1 : 0
resources.uniforms.uHasFrostedTexture.value = hasWallpaperTexture && frostedTarget ? 1 : 0
}
/** 解码并按当前质量预算缩放壁纸,不改变当前可见纹理。 */
@@ -2299,7 +2606,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
texture.generateMipmaps = false
texture.minFilter = three.LinearFilter
texture.magFilter = three.LinearFilter
return { hasWallpaperTexture: false, height: 1, texture, width: 1 }
return {
frostedTarget: null,
hasWallpaperTexture: false,
height: 1,
texture,
toneProfile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
width: 1,
}
}
const loader = new three.TextureLoader()
@@ -2333,10 +2647,19 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
texture.generateMipmaps = false
texture.minFilter = three.LinearFilter
texture.magFilter = three.LinearFilter
let frostedTarget: WebGLRenderTarget | null = null
try {
frostedTarget = await createFrostedWallpaperTarget(texture, textureWidth, textureHeight)
} catch (error) {
// 预滤属于磨砂优化;失败时保留源纹理并退回既有扩散采样,不能拖垮其他材质。
console.warn('玻璃磨砂壁纸预滤失败,继续使用实时扩散采样:', error)
}
return {
frostedTarget,
hasWallpaperTexture: true,
height: textureHeight,
texture,
toneProfile: analyzeGlassWallpaperTone(image, sourceWidth, sourceHeight),
width: textureWidth,
}
}
@@ -2344,7 +2667,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** 提前准备下一张纹理;失败不会影响当前活动纹理。 */
async function prepareWallpaper(url: string) {
const version = ++prepareVersion
preparedWallpaper?.texture.dispose()
if (preparedWallpaper) {
disposeWallpaperResources(preparedWallpaper.texture, preparedWallpaper.frostedTarget)
}
preparedWallpaper = null
preparedWallpaperUrl.value = ''
if (!url || !resources || url === toValue(options.wallpaperUrl)) {
@@ -2356,7 +2681,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const prepared = await createWallpaperTexture(url)
if (!prepared) return
if (version !== prepareVersion || !resources) {
prepared.texture.dispose()
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
return
}
@@ -2379,11 +2704,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
preparedWallpaperUrl.value = ''
}
if (version !== loadVersion || !resources) {
prepared.texture.dispose()
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
return
}
activateLoadedTexture(prepared.texture, prepared.width, prepared.height, prepared.hasWallpaperTexture)
activateLoadedTexture(
prepared.texture,
prepared.frostedTarget,
prepared.width,
prepared.height,
prepared.hasWallpaperTexture,
prepared.toneProfile,
)
await resources.renderer.compileAsync(resources.scene, resources.camera)
if (version !== loadVersion || !resources) return
@@ -2427,6 +2759,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
uFlowTexture: { value: null },
uFlowStrength: { value: getFlowStrengthScale() },
uHasFlowTexture: { value: 0 },
uHasFrostedTexture: { value: 0 },
uHasWallpaperTexture: { value: 0 },
uMotion: { value: 0 },
uMotionExpansion: { value: getMotionExpansion() },
@@ -2435,6 +2768,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
uPointerVelocity: { value: new three.Vector2(0, 0) },
uPresentationSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) },
uPreviousCoverScale: { value: new three.Vector2(1, 1) },
uPreviousWallpaperExposure: { value: DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure },
uQuality: { value: toValue(options.quality) === 'high' ? 1 : 0 },
uReflectionStrength: { value: getReflectionStrengthScale() },
uRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
@@ -2443,8 +2777,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
uPreviousTexture: { value: null },
uPreviousFrostedTexture: { value: null },
uTexture: { value: null },
uFrostedTexture: { value: null },
uTextureMix: { value: 1 },
uWallpaperExposure: { value: DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure },
uTintColor: { value: new three.Color(toValue(options.tintColor)) },
uTransparency: { value: getTransparency() },
uTransmissionStrength: {
@@ -2623,10 +2960,31 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
},
)
watch(
() => toValue(options.pageMotion?.revision ?? 0),
() => {
if (!resources || presentationSpace !== 'scroll') return
const timestamp = performance.now()
// 页面入场期间内容高度与 canvas CSS 尺寸可能同帧变化presentation 必须先于表面和清屏提交。
resizeRenderer()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
},
{ flush: 'sync' },
)
watch(
() => toValue(options.routeKey),
async (routeKey, previousRouteKey) => {
const previousProfile = getRenderProfile(previousRouteKey ?? '')
if (resources && presentationSpace === 'scroll' && options.pageMotion) {
pagePresentationGeometryReady = false
const timestamp = performance.now()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
}
await nextTick()
if (resources) {
const nextProfile = getRenderProfile(routeKey)
resizeRenderer()
@@ -2636,9 +2994,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
return
}
}
scheduleSurfaceUpdate()
if (presentationSpace === 'scroll' && options.pageMotion) scheduleSurfaceStabilityUpdate()
else scheduleSurfaceUpdate()
},
{ flush: 'post' },
)
onScopeDispose(() => {

View File

@@ -0,0 +1,224 @@
import { readonly, ref, type Ref } from 'vue'
export const PAGE_PRESENTATION_MOTION_DURATION_MS = 180
export const PAGE_PRESENTATION_MOTION_START_OPACITY = 0.88
export const PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y = 4
export const PAGE_PRESENTATION_LAYOUT_STABLE_MS = 120
export const PAGE_PRESENTATION_LAYOUT_HOLD_MAX_MS = 480
/** renderer 只读取同一帧已经提交到 DOM 的页面呈现状态。 */
export interface PagePresentationMotionReader {
/** 页面是否处于共享呈现事务中。 */
active: Readonly<Ref<boolean>>
/** 当前页面材质与 DOM 共同使用的透明度。 */
opacity: Readonly<Ref<number>>
/** 每次 DOM motion 样式提交后递增renderer 据此在同一帧刷新表面。 */
revision: Readonly<Ref<number>>
}
const active = ref(false)
const epoch = ref(0)
const opacity = ref(1)
const progress = ref(1)
const revision = ref(0)
const routeKey = ref('')
const translateY = ref(0)
let animationFrame: number | null = null
let layoutHoldStartedAt = 0
let layoutStableSince = 0
let layoutSignature = ''
let startedAt = 0
function sampleBezier(time: number, start: number, end: number) {
const inverse = 1 - time
return 3 * inverse * inverse * time * start + 3 * inverse * time * time * end + time * time * time
}
/** 计算玻璃页面统一使用的 `cubic-bezier(0.2, 0.8, 0.2, 1)` 进度。 */
export function getPagePresentationMotionProgress(elapsed: number, duration = PAGE_PRESENTATION_MOTION_DURATION_MS) {
if (duration <= 0 || elapsed >= duration) return 1
if (elapsed <= 0) return 0
const target = elapsed / duration
let lower = 0
let upper = 1
let parameter = target
for (let iteration = 0; iteration < 10; iteration += 1) {
parameter = (lower + upper) * 0.5
if (sampleBezier(parameter, 0.2, 0.2) < target) lower = parameter
else upper = parameter
}
return sampleBezier(parameter, 0.8, 1)
}
function clearDocumentMotionState() {
const root = document.documentElement
delete root.dataset.pagePresentationMotion
root.style.removeProperty('--mp-page-motion-opacity')
root.style.removeProperty('--mp-page-motion-translate-y')
}
/** 先提交 DOM 样式,再发布 revision保证 renderer 读取到同一帧的真实矩形。 */
function applyMotionFrame(nextProgress: number) {
const root = document.documentElement
const nextOpacity =
PAGE_PRESENTATION_MOTION_START_OPACITY + (1 - PAGE_PRESENTATION_MOTION_START_OPACITY) * nextProgress
const nextTranslateY = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y * (1 - nextProgress)
root.dataset.pagePresentationMotion = 'active'
root.style.setProperty('--mp-page-motion-opacity', nextOpacity.toFixed(4))
root.style.setProperty('--mp-page-motion-translate-y', `${nextTranslateY.toFixed(3)}px`)
opacity.value = nextOpacity
progress.value = nextProgress
translateY.value = nextTranslateY
revision.value += 1
}
/** 布局门关闭时 DOM 与 renderer 都不暴露尚未稳定的页面几何。 */
function applyLayoutHoldFrame() {
const root = document.documentElement
root.dataset.pagePresentationMotion = 'active'
root.style.setProperty('--mp-page-motion-opacity', '0')
root.style.setProperty('--mp-page-motion-translate-y', `${PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y}px`)
opacity.value = 0
progress.value = 0
translateY.value = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y
revision.value += 1
}
function getLayoutSignature(root: HTMLElement) {
return `${root.offsetWidth},${root.offsetHeight},${root.scrollWidth},${root.scrollHeight}`
}
function beginReveal(timestamp: number, motionEpoch: number) {
if (!active.value || epoch.value !== motionEpoch) return
startedAt = timestamp
applyMotionFrame(0)
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
}
/** 页面根持续稳定后才开始 reveal上限避免持续布局页面永久不可见。 */
function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElement) {
if (!active.value || epoch.value !== motionEpoch) return
animationFrame = null
const nextSignature = getLayoutSignature(root)
if (nextSignature !== layoutSignature) {
layoutSignature = nextSignature
layoutStableSince = timestamp
}
if (
timestamp - layoutStableSince >= PAGE_PRESENTATION_LAYOUT_STABLE_MS ||
timestamp - layoutHoldStartedAt >= PAGE_PRESENTATION_LAYOUT_HOLD_MAX_MS
) {
beginReveal(timestamp, motionEpoch)
return
}
animationFrame = window.requestAnimationFrame(nextTimestamp =>
sampleLayoutHold(nextTimestamp, motionEpoch, root),
)
}
function settleMotion() {
active.value = false
opacity.value = 1
progress.value = 1
translateY.value = 0
clearDocumentMotionState()
}
function cancel() {
const needsRendererCommit =
active.value ||
opacity.value !== 1 ||
translateY.value !== 0 ||
document.documentElement.dataset.pagePresentationMotion === 'active'
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
animationFrame = null
if (needsRendererCommit) epoch.value += 1
settleMotion()
if (needsRendererCommit) revision.value += 1
}
function renderFrame(timestamp: number, motionEpoch: number) {
if (!active.value || epoch.value !== motionEpoch) return
animationFrame = null
const nextProgress = getPagePresentationMotionProgress(timestamp - startedAt)
applyMotionFrame(nextProgress)
if (nextProgress < 1) {
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
return
}
settleMotion()
}
/**
* 玻璃主题由共享控制器接管页面入场;其他主题继续使用既有 CSS keyframe。
* 返回 true 表示本次路由变化已经处理,包括 reduced-motion 的即时提交。
*/
function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
if (document.documentElement.dataset.theme !== 'glass') {
cancel()
return false
}
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
animationFrame = null
epoch.value += 1
const motionEpoch = epoch.value
routeKey.value = nextRouteKey
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
settleMotion()
revision.value += 1
return true
}
active.value = true
const timestamp = performance.now()
if (layoutRoot) {
layoutHoldStartedAt = timestamp
layoutStableSince = timestamp
layoutSignature = getLayoutSignature(layoutRoot)
applyLayoutHoldFrame()
animationFrame = window.requestAnimationFrame(nextTimestamp =>
sampleLayoutHold(nextTimestamp, motionEpoch, layoutRoot),
)
} else {
beginReveal(timestamp, motionEpoch)
}
return true
}
const reader: PagePresentationMotionReader = {
active: readonly(active),
opacity: readonly(opacity),
revision: readonly(revision),
}
/** 提供默认布局与 glass renderer 共享的短时页面呈现事务。 */
export function usePagePresentationMotion() {
return {
active: readonly(active),
cancel,
epoch: readonly(epoch),
opacity: reader.opacity,
progress: readonly(progress),
reader,
revision: reader.revision,
routeKey: readonly(routeKey),
start,
translateY: readonly(translateY),
}
}

View File

@@ -1,7 +1,9 @@
<script lang="ts" setup>
import DefaultLayout from './default/components/DefaultLayout.vue'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
const route = useRoute()
const pagePresentationMotion = usePagePresentationMotion()
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
const routeCacheKey = computed(() => {
@@ -16,6 +18,7 @@ const routeCacheKey = computed(() => {
// 页面过渡按实际页面身份触发keep-alive 页面避免 query 变化时反复入场。
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
const isPageEntering = ref(false)
const pageRouteRef = ref<HTMLElement | null>(null)
let pageMotionTimer: number | null = null
let pageMotionFrame: number | null = null
@@ -32,9 +35,11 @@ function playPageEnterMotion() {
}
isPageEntering.value = false
if (pagePresentationMotion.start(routeTransitionKey.value, pageRouteRef.value)) return
pageMotionFrame = window.requestAnimationFrame(() => {
isPageEntering.value = true
pageMotionFrame = null
isPageEntering.value = true
pageMotionTimer = window.setTimeout(() => {
isPageEntering.value = false
pageMotionTimer = null
@@ -49,13 +54,14 @@ onMounted(playPageEnterMotion)
onBeforeUnmount(() => {
if (pageMotionTimer) window.clearTimeout(pageMotionTimer)
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
pagePresentationMotion.cancel()
})
</script>
<template>
<DefaultLayout>
<router-view v-slot="{ Component }">
<div class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
<div ref="pageRouteRef" class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
<keep-alive :max="24">
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
</keep-alive>

View File

@@ -44,6 +44,7 @@ const mocks = vi.hoisted(() => {
grid,
gridInit: vi.fn<(options: unknown, element: unknown) => unknown>(() => grid),
openSharedDialog: vi.fn(),
themeName: undefined as unknown as { value: string },
useDynamicButton: vi.fn(),
}
})
@@ -76,10 +77,12 @@ vi.mock('@/api', () => ({
vi.mock('vuetify', async importOriginal => {
const { ref } = await import('vue')
mocks.displayWidth = ref(1512)
mocks.themeName = ref('light')
return {
...(await importOriginal<typeof import('vuetify')>()),
useDisplay: () => ({ width: mocks.displayWidth }),
useTheme: () => ({ global: { name: mocks.themeName } }),
}
})
@@ -184,6 +187,7 @@ describe('dashboard page initial layout', () => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.displayWidth.value = 1512
mocks.themeName.value = 'light'
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
})
@@ -210,9 +214,9 @@ describe('dashboard page initial layout', () => {
expect(screen.getAllByTestId('dashboard-item')).toHaveLength(1)
expect(screen.getByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'systemInfo')
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement))
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: true }), expect.any(HTMLElement))
expect(container.querySelector('.dashboard-grid')).not.toHaveClass('is-revealed')
await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true))
await waitFor(() => expect(container.querySelector('.dashboard-grid')).toHaveClass('is-revealed'))
remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } })
remoteProfile.resolve({
@@ -228,6 +232,26 @@ describe('dashboard page initial layout', () => {
await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled())
})
it('disables automatic grid transitions only while browsing with the glass theme', async () => {
mocks.themeName.value = 'glass'
mocks.apiGet.mockImplementation((url: string) => {
if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') {
return { data: {} }
}
if (url === '/user/config/Dashboard') return { data: {} }
if (url === '/plugin/dashboard/meta') return []
throw new Error('Unexpected GET ' + url)
})
await renderDashboard()
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement))
await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(false))
await fireEvent.click(document.querySelector('.compact-fab--primary') as HTMLElement)
await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true))
})
it('keeps the upstream progressive default while an uncached remote profile is loading', async () => {
const remoteOrder = deferred<unknown>()
const remoteProfile = deferred<unknown>()
@@ -241,7 +265,7 @@ describe('dashboard page initial layout', () => {
await renderDashboard()
expect(screen.getAllByTestId('dashboard-item')).toHaveLength(10)
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement))
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: true }), expect.any(HTMLElement))
remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } })
remoteProfile.resolve({
@@ -746,7 +770,7 @@ describe('dashboard page initial layout', () => {
await waitFor(() => expect(mocks.grid.column).toHaveBeenCalledWith(1, 'list'))
await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(1))
await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(10))
expect(mocks.grid.setAnimation).toHaveBeenCalledWith(false)
expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true)
expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([
'storage',
'mediaStatistic',

View File

@@ -12,7 +12,7 @@ import { usePWA } from '@/composables/usePWA'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { useDisplay } from 'vuetify'
import { useDisplay, useTheme } from 'vuetify'
const ContentToggleSettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
@@ -24,6 +24,7 @@ const { t } = useI18n()
// PWA模式检测
const { appMode } = usePWA()
const display = useDisplay()
const vuetifyTheme = useTheme()
const userStore = useUserStore()
const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
@@ -97,9 +98,6 @@ interface DashboardGridItem {
// 是否处于仪表板布局编辑模式
const isLayoutEditing = ref(false)
// 首次布局完成后触发轻量整体入场,不延迟或隐藏渐进渲染内容。
const isDashboardGridRevealed = ref(false)
// 是否发送请求的总开关
const isRequest = ref(true)
@@ -148,7 +146,6 @@ let dashboardGridContentObserver: ResizeObserver | null = null
let dashboardGridContentResizeFrame: number | null = null
let dashboardGridResizeRefreshFrame: number | null = null
let dashboardGridAnimationFrame: number | null = null
let dashboardGridEntranceFrame: number | null = null
let dashboardRevealFrame: number | null = null
let isDashboardRevealPending = false
let dashboardProfileSaveQueue = Promise.resolve()
@@ -389,16 +386,6 @@ function scheduleDashboardReveal() {
})
}
// 首次 GridStack 坐标提交后的下一帧立即入场,不等待卡片内部异步数据。
function scheduleDashboardGridEntrance() {
if (isDashboardGridRevealed.value || dashboardGridEntranceFrame !== null || typeof window === 'undefined') return
dashboardGridEntranceFrame = requestAnimationFrame(() => {
dashboardGridEntranceFrame = null
isDashboardGridRevealed.value = true
})
}
// 程序化批量布局不播放中间态;稳定后恢复用户拖拽、缩放和让位动画。
function pauseDashboardGridAnimation() {
if (dashboardGridAnimationFrame !== null) {
@@ -408,15 +395,21 @@ function pauseDashboardGridAnimation() {
dashboardGrid.value?.setAnimation(false)
}
// 动画恢复延后一帧,确保 GridStack 已提交最终坐标后才重新响应交互
// 玻璃浏览态直接提交自动布局,普通主题和显式编辑交互保留 GridStack 过渡
function shouldAnimateDashboardGrid(editable = isLayoutEditing.value) {
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
return !reduceMotion && (editable || vuetifyTheme.global.name.value !== 'glass')
}
// 批量布局同步期间暂停几何过渡,稳定后恢复浏览与编辑状态的卡片动画。
function scheduleDashboardGridAnimationResume() {
if (typeof window === 'undefined') return
if (dashboardGridAnimationFrame !== null) cancelAnimationFrame(dashboardGridAnimationFrame)
dashboardGridAnimationFrame = requestAnimationFrame(() => {
dashboardGridAnimationFrame = null
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
dashboardGrid.value?.setAnimation(!reduceMotion)
dashboardGrid.value?.setAnimation(shouldAnimateDashboardGrid())
})
}
@@ -1192,7 +1185,7 @@ function initializeDashboardGrid() {
dashboardGrid.value = GridStack.init(
{
animate: false,
animate: shouldAnimateDashboardGrid(),
cellHeight: DASHBOARD_GRID_CELL_HEIGHT,
column: getDashboardGridColumnsForProfile(dashboardLayoutProfile.value),
draggable: {
@@ -1222,6 +1215,7 @@ function updateDashboardGridEditableState(editable: boolean) {
if (!dashboardGrid.value) return
dashboardGrid.value.setStatic(!editable)
dashboardGrid.value.setAnimation(shouldAnimateDashboardGrid(editable))
if (editable) {
dashboardGrid.value.enableMove(true)
dashboardGrid.value.enableResize(true)
@@ -1306,7 +1300,6 @@ async function syncDashboardGrid(resumeAnimation = true) {
resizeAutoDashboardItemsToContent()
scheduleDashboardReveal()
})
scheduleDashboardGridEntrance()
} finally {
isSyncingDashboardGrid.value = false
if (resumeAnimation) scheduleDashboardGridAnimationResume()
@@ -1547,6 +1540,11 @@ watch(isLayoutEditing, value => {
updateDashboardGridEditableState(value)
})
watch(
() => vuetifyTheme.global.name.value,
() => updateDashboardGridEditableState(isLayoutEditing.value),
)
watch(
dashboardGridItems,
() => {
@@ -1664,10 +1662,6 @@ onBeforeUnmount(() => {
cancelAnimationFrame(dashboardGridAnimationFrame)
dashboardGridAnimationFrame = null
}
if (dashboardGridEntranceFrame !== null) {
cancelAnimationFrame(dashboardGridEntranceFrame)
dashboardGridEntranceFrame = null
}
if (dashboardRevealFrame !== null) {
cancelAnimationFrame(dashboardRevealFrame)
dashboardRevealFrame = null
@@ -1685,7 +1679,7 @@ onBeforeUnmount(() => {
<div
ref="dashboardGridRef"
class="grid-stack dashboard-grid"
:class="{ 'is-editing': isLayoutEditing, 'is-revealed': isDashboardGridRevealed }"
:class="{ 'is-editing': isLayoutEditing }"
>
<div
v-for="gridItem in dashboardGridItems"
@@ -1753,17 +1747,7 @@ onBeforeUnmount(() => {
/* stylelint-disable selector-pseudo-class-no-unknown */
.dashboard-grid {
opacity: 0.92;
pointer-events: auto;
transform: translateY(4px);
transition:
opacity 0.18s ease-out,
transform 0.18s ease-out;
}
.dashboard-grid.is-revealed {
opacity: 1;
transform: none;
}
.dashboard-grid :deep(.v-card) {
@@ -1893,11 +1877,4 @@ onBeforeUnmount(() => {
inset-inline-end: -4px;
}
@media (prefers-reduced-motion: reduce) {
.dashboard-grid {
opacity: 1;
transform: none;
transition: none;
}
}
</style>

View File

@@ -237,39 +237,29 @@ onActivated(async () => {
<div>
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem value="themoviedb">
<transition name="fade-slide" appear>
<div>
<TheMovieDbView />
</div>
</transition>
<div>
<TheMovieDbView />
</div>
</VWindowItem>
<VWindowItem value="douban">
<transition name="fade-slide" appear>
<div>
<DoubanView />
</div>
</transition>
<div>
<DoubanView />
</div>
</VWindowItem>
<VWindowItem value="bangumi">
<transition name="fade-slide" appear>
<div>
<BangumiView />
</div>
</transition>
<div>
<BangumiView />
</div>
</VWindowItem>
<VWindowItem value="anilist">
<transition name="fade-slide" appear>
<div>
<AniListView />
</div>
</transition>
<div>
<AniListView />
</div>
</VWindowItem>
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
<transition name="fade-slide" appear>
<div>
<ExtraSourceView :source="item" />
</div>
</transition>
<div>
<ExtraSourceView :source="item" />
</div>
</VWindowItem>
</VWindow>
<!-- 快速滚动到顶部按钮 -->

View File

@@ -55,11 +55,9 @@ useKeepAliveRefresh(async () => {
<div v-if="downloaders.length > 0">
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem v-for="item in downloaders" :key="item.name" :value="item.name">
<transition name="fade-slide" appear>
<div>
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
</div>
</transition>
<div>
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
</div>
</VWindowItem>
</VWindow>
</div>

View File

@@ -1317,7 +1317,7 @@ onUnmounted(() => {
</script>
<template>
<div>
<div data-glass-optical-mode="static-material">
<!-- 搜索加载状态 -->
<VFadeTransition>
<div

View File

@@ -64,15 +64,9 @@ watch(activeTab, markTabVisited, { immediate: true })
<div>
<VWindow v-model="activeTab" class="settings-content-window disable-tab-transition" :touch="false">
<VWindowItem v-for="item in settingTabComponents" :key="item.value" :value="item.value">
<transition name="fade-slide" appear>
<div>
<component
:is="item.component"
v-if="visitedTabs.has(item.value)"
:active="activeTab === item.value"
/>
</div>
</transition>
<div>
<component :is="item.component" v-if="visitedTabs.has(item.value)" :active="activeTab === item.value" />
</div>
</VWindowItem>
</VWindow>
</div>

View File

@@ -342,9 +342,7 @@ const subscribeDynamicMenuItems = computed<DynamicButtonMenuItem[] | undefined>(
action: () => {},
},
{
titleKey: subscribeBatchState.value.allSelected
? 'subscribe.batchDeselectAll'
: 'subscribe.batchSelectAll',
titleKey: subscribeBatchState.value.allSelected ? 'subscribe.batchDeselectAll' : 'subscribe.batchSelectAll',
icon: subscribeBatchState.value.allSelected ? 'mdi-checkbox-blank-outline' : 'mdi-checkbox-multiple-marked',
permission: 'subscribe',
disabled: subscribeBatchState.value.totalCount === 0,
@@ -442,7 +440,9 @@ useDynamicButton({
menuItems: subscribeDynamicMenuItems,
permission: 'subscribe',
show: computed(
() => appMode.value && (subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
() =>
appMode.value &&
(subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
),
})
@@ -519,37 +519,31 @@ onMounted(() => {
<div>
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<div>
<SubscribeListView
ref="subscribeListViewRef"
:type="subType"
:subid="subId"
:keyword="subscribeFilter"
:status-filter="subscribeStatusFilter ?? ''"
:sort-mode="subscribeSortMode"
:sort-by="subscribeSortBy"
:active="activeTab === 'mysub'"
@update:sort-mode="subscribeSortMode = $event"
@update:sort-by="subscribeSortBy = $event"
@batch-state-change="handleSubscribeBatchStateChange"
/>
</div>
</transition>
<div>
<SubscribeListView
ref="subscribeListViewRef"
:type="subType"
:subid="subId"
:keyword="subscribeFilter"
:status-filter="subscribeStatusFilter ?? ''"
:sort-mode="subscribeSortMode"
:sort-by="subscribeSortBy"
:active="activeTab === 'mysub'"
@update:sort-mode="subscribeSortMode = $event"
@update:sort-by="subscribeSortBy = $event"
@batch-state-change="handleSubscribeBatchStateChange"
/>
</div>
</VWindowItem>
<VWindowItem value="popular">
<transition name="fade-slide" appear>
<div>
<SubscribePopularView :type="subType" />
</div>
</transition>
<div>
<SubscribePopularView :type="subType" />
</div>
</VWindowItem>
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<SubscribeShareView :keyword="shareKeyword" />
</div>
</transition>
<div>
<SubscribeShareView :keyword="shareKeyword" />
</div>
</VWindowItem>
</VWindow>
@@ -722,7 +716,6 @@ onMounted(() => {
/>
</div>
</Teleport>
</div>
</template>

View File

@@ -107,18 +107,14 @@ onMounted(() => {
<div>
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
<VWindowItem value="list">
<transition name="fade-slide" appear>
<div>
<WorkflowListView ref="workflowListViewRef" />
</div>
</transition>
<div>
<WorkflowListView ref="workflowListViewRef" />
</div>
</VWindowItem>
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
</div>
</transition>
<div>
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
</div>
</VWindowItem>
</VWindow>

View File

@@ -168,9 +168,9 @@ html[data-theme='glass'] {
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
&[data-glass-appearance='frosted'] {
--glass-surface: rgba(255, 255, 255, calc(0.12 - var(--glass-transparency, 0.5) * 0.07));
--glass-surface-soft: rgba(255, 255, 255, calc(0.11 - var(--glass-transparency, 0.5) * 0.06));
--glass-surface-raised: rgba(255, 255, 255, calc(0.16 - var(--glass-transparency, 0.5) * 0.08));
--glass-surface: rgba(255, 255, 255, calc(0.14 - var(--glass-transparency, 0.5) * 0.1));
--glass-surface-soft: rgba(255, 255, 255, calc(0.13 - var(--glass-transparency, 0.5) * 0.08));
--glass-surface-raised: rgba(255, 255, 255, calc(0.18 - var(--glass-transparency, 0.5) * 0.11));
--glass-control: rgba(255, 255, 255, 9%);
--glass-control-prominent: rgba(255, 255, 255, 10%);
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
@@ -195,11 +195,16 @@ html[data-theme='glass'] {
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness));
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
--glass-overlay-surface: var(--glass-surface-raised);
--glass-overlay-surface: rgba(
var(--v-theme-background),
calc(0.76 - var(--glass-transparency, 0.5) * 0.12)
);
--glass-overlay-blur: var(--glass-blur-raised);
--glass-overlay-saturate: var(--glass-saturate);
--glass-blur-surface: 28px;
--glass-saturate: 150%;
--glass-blur-surface: 40px;
--glass-blur: 40px;
--glass-blur-raised: 60px;
--glass-saturate: 180%;
--glass-control-icon-color: rgba(242, 245, 250, 78%);
--glass-control-placeholder-color: rgba(242, 245, 250, 62%);
--glass-control-shortcut-background: rgba(255, 255, 255, 11%);
@@ -210,6 +215,10 @@ html[data-theme='glass'] {
--glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness));
.v-overlay__scrim {
background: rgba(3, 7, 18, 62%);
}
// 磨砂弹窗只由最外层表面采样背景,内部卡片与自定义分组表面保持扁平。
.v-overlay__content > :where(.v-card, .v-sheet),
.v-overlay__content > form > :where(.v-card, .v-sheet) {
@@ -302,38 +311,23 @@ html[data-theme='glass'] {
z-index: 1;
}
// 页面路由只使用短距离透明度与位移反馈,避免触发整页滤镜重采样
// 页面 DOM 与 scroll renderer 共用同一 motion 时钟canvas 本身保持壁纸坐标不动
.mp-page-route--entering {
animation: glass-page-route-enter var(--mp-motion-duration-page) var(--mp-motion-ease-standard) both;
animation: none;
filter: none;
opacity: 1;
transform: none;
will-change: auto;
}
&[data-page-presentation-motion='active'] .mp-page-route {
filter: none;
opacity: var(--mp-page-motion-opacity, 1);
transform: translate3d(0, var(--mp-page-motion-translate-y, 0), 0);
transform-origin: center top;
will-change: opacity, transform;
}
@keyframes glass-page-route-enter {
from {
opacity: 0.82;
transform: translate3d(0, 2px, 0);
}
to {
opacity: 1;
transform: none;
}
}
.mp-page-enter-active,
.mp-page-leave-active {
filter: none !important;
transition:
opacity var(--mp-motion-duration-page) var(--mp-motion-ease-standard),
transform var(--mp-motion-duration-page) var(--mp-motion-ease-standard);
}
.mp-page-enter-from {
filter: none !important;
opacity: 0.82;
transform: translate3d(0, 2px, 0);
}
:where(
.app-surface:not(.no-blur),
.v-card:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
@@ -827,13 +821,6 @@ html[data-theme='glass'] {
--mobile-calendar-surface-blur: var(--glass-surface-backdrop-filter);
}
// 磨砂卡片不参与仪表板整体透明度入场,避免合成层切换造成先透明后模糊的闪变。
&[data-glass-appearance='frosted'] .dashboard-grid {
opacity: 1;
transform: none;
transition: none;
}
// 用户权限编辑器由普通容器构成,通过主题变量接入玻璃表面材质。
.user-permission-editor {
--permission-editor-border: 1px solid var(--glass-border);
@@ -1040,8 +1027,8 @@ html[data-theme='glass'][data-glass-appearance='frosted']:is(
[data-glass-quality='high']
)[data-glass-renderer-state='ready'] {
--glass-navbar-backdrop-filter: none;
--glass-overlay-blur: 8px;
--glass-overlay-saturate: 145%;
--glass-overlay-blur: 0px;
--glass-overlay-saturate: 100%;
}
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']

View File

@@ -66,7 +66,7 @@ describe('glass optics geometry', () => {
expect(getGlassOpticalReflectionStrengthScale(0)).toBe(0)
expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1)
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28)
expect(getGlassOpticalTransparency(0)).toBe(0)
expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6)
expect(getGlassOpticalTransparency(70)).toBeCloseTo(0.96)
expect(getGlassOpticalTransparency(100)).toBeCloseTo(1.1)
@@ -76,9 +76,9 @@ describe('glass optics geometry', () => {
expect(getGlassOpticalTransmissionStrength(0)).toBe(0)
expect(getGlassOpticalTransmissionStrength(70)).toBe(1)
expect(getGlassOpticalTransmissionStrength(100)).toBe(1.3)
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.82)
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1.28)
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.48)
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.84)
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1)
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.08)
})
it('keeps presets as concrete six-parameter values', () => {

View File

@@ -0,0 +1,35 @@
import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, getGlassWallpaperToneProfile } from '@/utils/glassWallpaperTone'
import { describe, expect, it } from 'vitest'
describe('glass wallpaper tone profile', () => {
it('keeps a representative mid-tone wallpaper near neutral exposure', () => {
const profile = getGlassWallpaperToneProfile([0.18, 0.3, 0.36, 0.4, 0.52, 0.72, 0.82])
expect(profile.medianLuminance).toBeCloseTo(0.4)
expect(profile.highlightLuminance).toBeCloseTo(0.72)
expect(profile.exposure).toBeGreaterThan(0.98)
expect(profile.exposure).toBeLessThan(1.04)
})
it('uses bounded compensation without erasing bright and dark wallpaper character', () => {
const dark = getGlassWallpaperToneProfile([0.01, 0.03, 0.06, 0.1, 0.14, 0.22, 0.32])
const bright = getGlassWallpaperToneProfile([0.42, 0.58, 0.7, 0.78, 0.86, 0.94, 1])
expect(dark.exposure).toBe(1.14)
expect(bright.exposure).toBeGreaterThanOrEqual(0.88)
expect(bright.exposure).toBeLessThan(0.92)
expect(dark.exposure).toBeGreaterThan(bright.exposure)
})
it('uses the highlight percentile to lower exposure for locally overbright wallpapers', () => {
const controlled = getGlassWallpaperToneProfile([0.2, 0.28, 0.34, 0.38, 0.42, 0.5, 0.58])
const highlighted = getGlassWallpaperToneProfile([0.2, 0.28, 0.34, 0.38, 0.42, 0.95, 1])
expect(highlighted.medianLuminance).toBe(controlled.medianLuminance)
expect(highlighted.exposure).toBeLessThan(controlled.exposure)
})
it('falls back to the neutral profile when no valid samples exist', () => {
expect(getGlassWallpaperToneProfile([Number.NaN])).toEqual(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
})
})

View File

@@ -290,7 +290,7 @@ export function getGlassOpticalTransparency(value: unknown) {
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
const progress = normalized / GLASS_OPTICAL_REFERENCE_STRENGTH
return 0.28 + 0.68 * progress ** 1.25
return 0.96 * progress ** 0.83
}
const highRangeProgress =
@@ -299,19 +299,19 @@ export function getGlassOpticalTransparency(value: unknown) {
return 0.96 + 0.14 * highRangeProgress ** 1.35
}
/** 标准 CSS 材质使用受控亮度曲线,并在高区间保留有限余量。 */
/** 标准 CSS 材质在壁纸归一化后只做窄幅目标亮度调整,避免重新放大原始明暗差异。 */
export function getGlassOpticalCssTransmissionBrightness(value: unknown) {
const transmission = getGlassOpticalTransmissionStrength(value)
if (transmission <= 1) {
return 0.82 + 0.46 * transmission ** 1.1
return 0.84 + 0.16 * transmission ** 1.05
}
const progress = (transmission - 1) / 0.3
return 1.28 + 0.2 * progress ** 1.2
return 1 + 0.08 * progress ** 1.1
}
/** 实时 renderer 接收透射响应,并在 shader 中按材质和质量执行高亮保护。 */
/** 实时 renderer 以 70 为归一化目标亮度参考,并在 shader 中按材质和质量执行高亮保护。 */
export function getGlassOpticalTransmissionStrength(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {

View File

@@ -0,0 +1,132 @@
export interface GlassWallpaperToneProfile {
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
exposure: number
/** 壁纸采样的高亮分位亮度,用于约束亮场曝光。 */
highlightLuminance: number
/** 壁纸采样的中位亮度,用于确定稳健的整体曝光。 */
medianLuminance: number
}
const ANALYSIS_MAX_EDGE = 64
const PROFILE_CACHE_LIMIT = 32
const PROFILE_LOAD_TIMEOUT_MS = 3000
const EXPOSURE_MIN = 0.88
const EXPOSURE_MAX = 1.14
const MEDIAN_TARGET = 0.38
const HIGHLIGHT_TARGET = 0.82
export const DEFAULT_GLASS_WALLPAPER_TONE_PROFILE: GlassWallpaperToneProfile = {
exposure: 1,
highlightLuminance: HIGHLIGHT_TARGET,
medianLuminance: MEDIAN_TARGET,
}
const profileCache = new Map<string, Promise<GlassWallpaperToneProfile>>()
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function readPercentile(sorted: number[], percentile: number) {
if (!sorted.length) return 0
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * percentile)))]
}
/**
* 以中位亮度为主、高亮分位为辅计算有限曝光。
* 指数响应只缩小跨壁纸差异,保留亮场与暗场各自的视觉性格。
*/
export function getGlassWallpaperToneProfile(luminances: number[]): GlassWallpaperToneProfile {
const sorted = luminances
.filter(Number.isFinite)
.map(value => clamp(value, 0, 1))
.sort((a, b) => a - b)
if (!sorted.length) return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
const medianLuminance = readPercentile(sorted, 0.5)
const highlightLuminance = readPercentile(sorted, 0.9)
const medianExposure = (MEDIAN_TARGET / Math.max(medianLuminance, 0.06)) ** 0.22
const highlightExposure = (HIGHLIGHT_TARGET / Math.max(highlightLuminance, 0.15)) ** 0.16
const exposure = clamp(medianExposure * 0.75 + highlightExposure * 0.25, EXPOSURE_MIN, EXPOSURE_MAX)
return {
exposure,
highlightLuminance,
medianLuminance,
}
}
/** 从已解码图片生成与 renderer、DOM 背景层共用的稳健亮度 profile。 */
export function analyzeGlassWallpaperTone(image: CanvasImageSource, width: number, height: number) {
if (typeof document === 'undefined' || width <= 0 || height <= 0) {
return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
}
try {
const scale = Math.min(1, ANALYSIS_MAX_EDGE / Math.max(width, height))
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(width * scale))
canvas.height = Math.max(1, Math.round(height * scale))
const context = canvas.getContext('2d', { willReadFrequently: true })
if (!context) return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
context.drawImage(image, 0, 0, canvas.width, canvas.height)
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data
const luminances: number[] = []
for (let offset = 0; offset < pixels.length; offset += 4) {
if (pixels[offset + 3] < 128) continue
luminances.push((pixels[offset] * 0.2126 + pixels[offset + 1] * 0.7152 + pixels[offset + 2] * 0.0722) / 255)
}
return getGlassWallpaperToneProfile(luminances)
} catch {
return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
}
}
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile>) {
if (profileCache.size >= PROFILE_CACHE_LIMIT) {
const oldestKey = profileCache.keys().next().value
if (oldestKey) profileCache.delete(oldestKey)
}
profileCache.set(url, profile)
return profile
}
/**
* 为 DOM 背景层加载可读像素。跨域来源不支持 CORS 时回落中性 profile
* 避免亮度分析阻断壁纸本身的 CSS 显示能力。
*/
export function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
if (!url || typeof Image === 'undefined') return Promise.resolve({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
const cached = profileCache.get(url)
if (cached) return cached
const profile = new Promise<GlassWallpaperToneProfile>(resolve => {
const image = new Image()
let settled = false
const finish = (result: GlassWallpaperToneProfile) => {
if (settled) return
settled = true
window.clearTimeout(timeout)
resolve(result)
}
const timeout = window.setTimeout(
() => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }),
PROFILE_LOAD_TIMEOUT_MS,
)
image.crossOrigin = 'anonymous'
image.decoding = 'async'
image.onload = () =>
finish(analyzeGlassWallpaperTone(image, image.naturalWidth || image.width, image.naturalHeight || image.height))
image.onerror = () => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
image.src = url
})
return rememberProfile(url, profile)
}

View File

@@ -1716,190 +1716,186 @@ function onDragStartPlugin(evt: any) {
<VWindow v-model="activeTab" class="disable-tab-transition px-2" :touch="false">
<!-- 我的插件 -->
<VWindowItem value="installed">
<transition name="fade-slide" appear>
<div>
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 py-0 app-surface-static">
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
<span>{{ t('common.sortModeHint') }}</span>
<VBtn variant="tonal" color="error" @click="sortMode = false">
{{ t('common.exit') }}
</VBtn>
</div>
</VAlert>
<!-- 文件夹和插件网格 -->
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
<!-- 混合排序列表文件夹和插件 -->
<template v-if="!currentFolder">
<!-- 主列表使用draggable进行混合排序 -->
<Draggable
v-if="canDragSort"
v-model="mixedSortList"
@end="saveMixedSortOrder"
@start="onDragStartPlugin"
item-key="id"
tag="div"
class="grid gap-4 grid-plugin-card"
group="mixed"
>
<template #item="{ element }">
<PluginMixedSortCard
:item="element"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:sortable="true"
@open-folder="openFolder"
@delete-folder="deleteFolder"
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
@refresh-data="refreshData"
@action-done="
pluginId => {
pluginActions[pluginId] = false
}
"
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
/>
</template>
</Draggable>
<ProgressiveCardGrid
v-else-if="shouldVirtualizeInstalledMainList"
:items="mixedSortList"
:get-item-key="item => `${item.type}:${item.id}`"
:min-item-width="256"
:estimated-item-height="180"
:scroll-to-index="installedScrollToIndex"
>
<template #default="{ item }">
<PluginMixedSortCard
:item="item"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:sortable="false"
@open-folder="openFolder"
@delete-folder="deleteFolder"
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
@refresh-data="refreshData"
@action-done="
pluginId => {
pluginActions[pluginId] = false
}
"
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
/>
</template>
</ProgressiveCardGrid>
</template>
<template v-else>
<!-- 文件夹内使用draggable排序 + 移出按钮 -->
<Draggable
v-if="canDragSort"
v-model="draggableFolderPlugins"
@end="saveFolderPluginOrder"
@start="onDragStartPlugin"
item-key="id"
tag="div"
class="grid gap-4 grid-plugin-card"
group="plugins"
>
<template #item="{ element }">
<PluginMixedSortCard
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:sortable="true"
:show-remove-button="true"
@refresh-data="refreshData"
@action-done="
pluginId => {
pluginActions[pluginId] = false
}
"
@remove-from-folder="removeFromFolder"
/>
</template>
</Draggable>
<ProgressiveCardGrid
v-else-if="shouldVirtualizeInstalledFolderList"
:items="draggableFolderPlugins"
:get-item-key="item => item.id"
:min-item-width="256"
:estimated-item-height="180"
>
<template #default="{ item }">
<PluginMixedSortCard
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:sortable="false"
:show-remove-button="true"
@refresh-data="refreshData"
@action-done="
pluginId => {
pluginActions[pluginId] = false
}
"
@remove-from-folder="removeFromFolder"
/>
</template>
</ProgressiveCardGrid>
</template>
<div>
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 py-0 app-surface-static">
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
<span>{{ t('common.sortModeHint') }}</span>
<VBtn variant="tonal" color="error" @click="sortMode = false">
{{ t('common.exit') }}
</VBtn>
</div>
</VAlert>
<NoDataFound
v-if="displayedFolders.length === 0 && displayedPlugins.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="
installedFilter || hasUpdateFilter ? t('plugin.noMatchingContent') : t('plugin.pleaseInstallFromMarket')
"
/>
<!-- 文件夹和插件网格 -->
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
<!-- 混合排序列表文件夹和插件 -->
<template v-if="!currentFolder">
<!-- 主列表使用draggable进行混合排序 -->
<Draggable
v-if="canDragSort"
v-model="mixedSortList"
@end="saveMixedSortOrder"
@start="onDragStartPlugin"
item-key="id"
tag="div"
class="grid gap-4 grid-plugin-card"
group="mixed"
>
<template #item="{ element }">
<PluginMixedSortCard
:item="element"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:sortable="true"
@open-folder="openFolder"
@delete-folder="deleteFolder"
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
@refresh-data="refreshData"
@action-done="
pluginId => {
pluginActions[pluginId] = false
}
"
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
/>
</template>
</Draggable>
<ProgressiveCardGrid
v-else-if="shouldVirtualizeInstalledMainList"
:items="mixedSortList"
:get-item-key="item => `${item.type}:${item.id}`"
:min-item-width="256"
:estimated-item-height="180"
:scroll-to-index="installedScrollToIndex"
>
<template #default="{ item }">
<PluginMixedSortCard
:item="item"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:sortable="false"
@open-folder="openFolder"
@delete-folder="deleteFolder"
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
@refresh-data="refreshData"
@action-done="
pluginId => {
pluginActions[pluginId] = false
}
"
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
/>
</template>
</ProgressiveCardGrid>
</template>
<template v-else>
<!-- 文件夹内使用draggable排序 + 移出按钮 -->
<Draggable
v-if="canDragSort"
v-model="draggableFolderPlugins"
@end="saveFolderPluginOrder"
@start="onDragStartPlugin"
item-key="id"
tag="div"
class="grid gap-4 grid-plugin-card"
group="plugins"
>
<template #item="{ element }">
<PluginMixedSortCard
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:sortable="true"
:show-remove-button="true"
@refresh-data="refreshData"
@action-done="
pluginId => {
pluginActions[pluginId] = false
}
"
@remove-from-folder="removeFromFolder"
/>
</template>
</Draggable>
<ProgressiveCardGrid
v-else-if="shouldVirtualizeInstalledFolderList"
:items="draggableFolderPlugins"
:get-item-key="item => item.id"
:min-item-width="256"
:estimated-item-height="180"
>
<template #default="{ item }">
<PluginMixedSortCard
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:sortable="false"
:show-remove-button="true"
@refresh-data="refreshData"
@action-done="
pluginId => {
pluginActions[pluginId] = false
}
"
@remove-from-folder="removeFromFolder"
/>
</template>
</ProgressiveCardGrid>
</template>
</div>
</transition>
<NoDataFound
v-if="displayedFolders.length === 0 && displayedPlugins.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="
installedFilter || hasUpdateFilter ? t('plugin.noMatchingContent') : t('plugin.pleaseInstallFromMarket')
"
/>
</div>
</VWindowItem>
<!-- 插件市场 -->
<VWindowItem value="market">
<transition name="fade-slide" appear>
<div>
<LoadingBanner
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
class="mt-12"
/>
<!-- 资源列表 -->
<VInfiniteScroll
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
mode="intersect"
side="end"
<div>
<LoadingBanner
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
class="mt-12"
/>
<!-- 资源列表 -->
<VInfiniteScroll
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
mode="intersect"
side="end"
:items="displayUninstalledList"
@load="loadMarketMore"
class="overflow-visible"
>
<template #loading />
<template #empty />
<ProgressiveCardGrid
v-if="displayUninstalledList.length > 0"
:items="displayUninstalledList"
@load="loadMarketMore"
class="overflow-visible"
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
:min-item-width="256"
:estimated-item-height="260"
>
<template #loading />
<template #empty />
<ProgressiveCardGrid
v-if="displayUninstalledList.length > 0"
:items="displayUninstalledList"
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
:min-item-width="256"
:estimated-item-height="260"
>
<template #default="{ item }">
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
</template>
</ProgressiveCardGrid>
</VInfiniteScroll>
<NoDataFound
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('plugin.allPluginsInstalled')"
/>
</div>
</transition>
<template #default="{ item }">
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
</template>
</ProgressiveCardGrid>
</VInfiniteScroll>
<NoDataFound
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('plugin.allPluginsInstalled')"
/>
</div>
</VWindowItem>
</VWindow>
</div>