mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 00:54:42 +08:00
feat(glass): refine material response and rendering lifecycle (#596)
This commit is contained in:
27
src/@core/utils/corsImage.ts
Normal file
27
src/@core/utils/corsImage.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** 建立带 CORS 响应头的浏览器缓存,必要时修复旧的非 CORS 缓存条目。 */
|
||||
export async function preloadCorsImage(url: string): Promise<boolean> {
|
||||
const request = async (cache: RequestCache) => {
|
||||
const source = new URL(url, window.location.href)
|
||||
const response = await fetch(source, {
|
||||
cache,
|
||||
credentials: source.origin === window.location.origin ? 'same-origin' : 'omit',
|
||||
mode: 'cors',
|
||||
})
|
||||
if (!response.ok) return false
|
||||
|
||||
await response.blob()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
if (await request('force-cache')) return true
|
||||
} catch {
|
||||
// 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。
|
||||
}
|
||||
|
||||
try {
|
||||
return await request('reload')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import ColorThief from 'colorthief'
|
||||
export { preloadCorsImage } from './corsImage'
|
||||
|
||||
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
||||
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
||||
@@ -96,31 +97,3 @@ export async function preloadImage(url: string): Promise<boolean> {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 在纹理加载前建立带 CORS 响应头的缓存,避免普通图片缓存污染 WebGL 读取。 */
|
||||
export async function preloadCorsImage(url: string): Promise<boolean> {
|
||||
const request = async (cache: RequestCache) => {
|
||||
const source = new URL(url, window.location.href)
|
||||
const response = await fetch(source, {
|
||||
cache,
|
||||
credentials: source.origin === window.location.origin ? 'same-origin' : 'omit',
|
||||
mode: 'cors',
|
||||
})
|
||||
if (!response.ok) return false
|
||||
|
||||
await response.blob()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
if (await request('force-cache')) return true
|
||||
} catch {
|
||||
// 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。
|
||||
}
|
||||
|
||||
try {
|
||||
return await request('reload')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
303
src/App.vue
303
src/App.vue
@@ -7,7 +7,7 @@ import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||
import { preloadCorsImage, preloadImage } from './@core/utils/image'
|
||||
import { preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
@@ -27,6 +27,7 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
|
||||
import {
|
||||
BACKGROUND_ROTATION_GRACE_MS,
|
||||
createBackgroundCandidateOrderResolver,
|
||||
@@ -45,7 +46,7 @@ import {
|
||||
} from '@/utils/loginPresentation'
|
||||
import {
|
||||
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||
loadGlassWallpaperToneProfile,
|
||||
loadGlassWallpaperTone,
|
||||
type GlassWallpaperToneProfile,
|
||||
} from '@/utils/glassWallpaperTone'
|
||||
|
||||
@@ -97,6 +98,17 @@ function resolveInitialThemeName(themePreference: string) {
|
||||
return resolveThemeName(themePreference)
|
||||
}
|
||||
|
||||
function recordGlassLaunchTiming(stage: string, detail?: string) {
|
||||
const timingWindow = window as typeof window & {
|
||||
__glassPerformanceProbeEnabled?: boolean
|
||||
__glassLaunchTimings?: Array<{ detail?: string; stage: string; time: number }>
|
||||
}
|
||||
if (!import.meta.env.DEV || !timingWindow.__glassPerformanceProbeEnabled) return
|
||||
|
||||
timingWindow.__glassLaunchTimings ??= []
|
||||
timingWindow.__glassLaunchTimings.push({ detail, stage, time: performance.now() })
|
||||
}
|
||||
|
||||
// 生效主题
|
||||
const vuetifyTheme = useTheme()
|
||||
const { global: globalTheme } = vuetifyTheme
|
||||
@@ -135,12 +147,22 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
// 背景图片
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const backgroundLayers = ref(createLoginBackgroundLayers())
|
||||
const backgroundDisplayImages = ref<Record<string, string>>({})
|
||||
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
|
||||
const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
const isBackgroundCrossfading = ref(false)
|
||||
const backgroundCrossfadeStartedAt = ref(0)
|
||||
const pendingOpticalBackgroundImage = ref('')
|
||||
const {
|
||||
acknowledgeActivated: acknowledgeOpticalWallpaperActivated,
|
||||
acknowledgePrepared: acknowledgeOpticalWallpaperPrepared,
|
||||
activationRevision: activateOpticalWallpaperRevision,
|
||||
cancel: cancelOpticalWallpaperTransaction,
|
||||
requestedRevision: pendingOpticalWallpaperRevision,
|
||||
requestedUrl: pendingOpticalBackgroundImage,
|
||||
requestActivation: requestOpticalWallpaperActivation,
|
||||
requestPreparation: requestOpticalWallpaperPreparation,
|
||||
} = useGlassWallpaperTransaction<number>()
|
||||
const resolveBackgroundCandidateOrder = createBackgroundCandidateOrderResolver()
|
||||
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
@@ -213,13 +235,16 @@ const shouldLoadBackgroundImages = computed(
|
||||
)
|
||||
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
|
||||
const renderedBackgroundLayers = computed(() => backgroundLayers.value)
|
||||
const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value))
|
||||
const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value))
|
||||
const getOpticalBackgroundImage = (imageUrl: string) => imageUrl
|
||||
const getPreparedBackgroundImage = (imageUrl: string) => backgroundDisplayImages.value[imageUrl] ?? imageUrl
|
||||
const getPreparedOpticalBackgroundImage = (imageUrl: string) =>
|
||||
backgroundDisplayImages.value[imageUrl] ?? getOpticalBackgroundImage(imageUrl)
|
||||
const activeOpticalBackgroundImage = computed(() => getPreparedOpticalBackgroundImage(activeBackgroundImage.value))
|
||||
const previousOpticalBackgroundImage = computed(() => {
|
||||
const previousIndex = previousImageIndex.value
|
||||
if (previousIndex === null) return ''
|
||||
|
||||
return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
|
||||
return getPreparedOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
|
||||
})
|
||||
const shouldRenderGlassOpticalLayer = computed(
|
||||
() =>
|
||||
@@ -242,12 +267,12 @@ const shouldRenderGlobalBlurLayer = computed(
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
let backgroundCrossfadeTimer: number | null = null
|
||||
let pendingOpticalWallpaperTimer: number | null = null
|
||||
let pendingOpticalWallpaperResolve: ((ready: boolean) => void) | null = null
|
||||
let authenticatedStateTimer: number | null = null
|
||||
let backgroundLoadVersion = 0
|
||||
let backgroundRecoveryAttemptedVersion = -1
|
||||
let backgroundRotationVersion = 0
|
||||
let backgroundPreloadIdleHandle: number | null = null
|
||||
let backgroundPreloadTimer: number | null = null
|
||||
|
||||
// 读取并同步透明主题背景设置到根组件响应式状态。
|
||||
function applyTransparentBackgroundSettings() {
|
||||
@@ -265,27 +290,15 @@ 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
|
||||
const displayUrl = isGlassTheme.value ? getPreparedBackgroundImage(layer.url) : layer.url
|
||||
|
||||
return {
|
||||
'backgroundImage': layer.url ? `url(${layer.url})` : undefined,
|
||||
'backgroundImage': displayUrl ? `url(${displayUrl})` : undefined,
|
||||
'--glass-wallpaper-brightness': String(materialExposure * profile.exposure),
|
||||
}
|
||||
}
|
||||
@@ -512,35 +525,39 @@ function clearBackgroundCrossfadeTimer() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 结束当前 GPU 纹理预备等待,旧请求不得继续提交壁纸切换。 */
|
||||
function settlePendingOpticalWallpaper(ready: boolean) {
|
||||
if (pendingOpticalWallpaperTimer !== null) {
|
||||
window.clearTimeout(pendingOpticalWallpaperTimer)
|
||||
pendingOpticalWallpaperTimer = null
|
||||
}
|
||||
pendingOpticalBackgroundImage.value = ''
|
||||
pendingOpticalWallpaperResolve?.(ready)
|
||||
pendingOpticalWallpaperResolve = null
|
||||
}
|
||||
|
||||
/** 等待两个 WebGL 呈现 context 完成下一张纹理上传。 */
|
||||
function prepareOpticalWallpaper(url: string) {
|
||||
async function prepareOpticalWallpaper(url: string) {
|
||||
if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) {
|
||||
return Promise.resolve(true)
|
||||
return { ready: true, revision: 0 }
|
||||
}
|
||||
|
||||
settlePendingOpticalWallpaper(false)
|
||||
pendingOpticalBackgroundImage.value = url
|
||||
const ready = requestOpticalWallpaperPreparation(url)
|
||||
const revision = pendingOpticalWallpaperRevision.value
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
pendingOpticalWallpaperResolve = resolve
|
||||
pendingOpticalWallpaperTimer = window.setTimeout(() => settlePendingOpticalWallpaper(false), 10000)
|
||||
})
|
||||
return { ready: await ready, revision }
|
||||
}
|
||||
|
||||
/** 只接受当前待切换 URL 的 renderer 就绪回执。 */
|
||||
function handleOpticalWallpaperPrepared(url: string) {
|
||||
if (url === pendingOpticalBackgroundImage.value) settlePendingOpticalWallpaper(true)
|
||||
function handleOpticalWallpaperPrepared(url: string, revision: number) {
|
||||
acknowledgeOpticalWallpaperPrepared(url, revision)
|
||||
}
|
||||
|
||||
/** 任一 context 无法准备当前候选时取消 revision,禁止后续材质刷新重复加载失效 URL。 */
|
||||
function handleOpticalWallpaperPreparationFailed(_url: string, revision: number) {
|
||||
cancelOpticalWallpaperTransaction(revision)
|
||||
}
|
||||
|
||||
/** 两个 context 均已原子消费 prepared 资源后,以同一时钟提交 DOM 壁纸。 */
|
||||
function handleOpticalWallpaperActivated(url: string, revision: number, startedAt: number) {
|
||||
const activation = acknowledgeOpticalWallpaperActivated(url, revision, startedAt)
|
||||
if (!activation) return
|
||||
|
||||
activateBackgroundImage(activation.payload, activation.startedAt)
|
||||
}
|
||||
|
||||
/** 任一 context 提交失败时取消整个 revision,禁止另一 context 继续持有半提交状态。 */
|
||||
function handleOpticalWallpaperActivationFailed(_url: string, revision: number) {
|
||||
cancelOpticalWallpaperTransaction(revision)
|
||||
}
|
||||
|
||||
// 重置背景图交叉淡入淡出状态。
|
||||
@@ -553,22 +570,23 @@ function resetBackgroundCrossfade() {
|
||||
}
|
||||
|
||||
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
|
||||
function activateBackgroundImage(nextIndex: number) {
|
||||
function activateBackgroundImage(nextIndex: number, startedAt = performance.now()) {
|
||||
if (nextIndex === activeImageIndex.value) return
|
||||
|
||||
clearBackgroundCrossfadeTimer()
|
||||
backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '')
|
||||
previousImageIndex.value = activeImageIndex.value
|
||||
isBackgroundCrossfading.value = true
|
||||
backgroundCrossfadeStartedAt.value = performance.now()
|
||||
backgroundCrossfadeStartedAt.value = startedAt
|
||||
activeImageIndex.value = nextIndex
|
||||
backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value)
|
||||
const remainingDuration = Math.max(0, BACKGROUND_CROSSFADE_DURATION_MS - (performance.now() - startedAt))
|
||||
backgroundCrossfadeTimer = window.setTimeout(() => {
|
||||
previousImageIndex.value = null
|
||||
isBackgroundCrossfading.value = false
|
||||
backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value)
|
||||
backgroundCrossfadeTimer = null
|
||||
}, BACKGROUND_CROSSFADE_DURATION_MS)
|
||||
}, remainingDuration)
|
||||
}
|
||||
|
||||
// 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。
|
||||
@@ -592,31 +610,86 @@ function preloadNextBackgroundImage() {
|
||||
void preloadBackgroundCandidate(backgroundImages.value[nextIndex])
|
||||
}
|
||||
|
||||
/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */
|
||||
function cancelNextBackgroundPreload() {
|
||||
const idleWindow = window as typeof window & {
|
||||
cancelIdleCallback?: (handle: number) => void
|
||||
}
|
||||
if (backgroundPreloadIdleHandle !== null) {
|
||||
idleWindow.cancelIdleCallback?.(backgroundPreloadIdleHandle)
|
||||
backgroundPreloadIdleHandle = null
|
||||
}
|
||||
if (backgroundPreloadTimer !== null) {
|
||||
window.clearTimeout(backgroundPreloadTimer)
|
||||
backgroundPreloadTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 首屏稳定后才预备轮播候选,避免第二张完整壁纸与 Dashboard 和首张纹理争抢资源。 */
|
||||
function scheduleNextBackgroundPreload() {
|
||||
cancelNextBackgroundPreload()
|
||||
if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1 || document.getElementById('loading-bg')) {
|
||||
return
|
||||
}
|
||||
|
||||
const idleWindow = window as typeof window & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number
|
||||
}
|
||||
if (typeof idleWindow.requestIdleCallback === 'function') {
|
||||
backgroundPreloadIdleHandle = idleWindow.requestIdleCallback(
|
||||
() => {
|
||||
backgroundPreloadIdleHandle = null
|
||||
preloadNextBackgroundImage()
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
backgroundPreloadTimer = window.setTimeout(() => {
|
||||
backgroundPreloadTimer = null
|
||||
preloadNextBackgroundImage()
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
/** 玻璃壁纸以一次匿名解码同时完成可读性和 tone 分析,失败时仍允许 CSS 回退。 */
|
||||
async function preloadBackgroundCandidate(imageUrl: string) {
|
||||
const toneProfile = isGlassTheme.value
|
||||
? ensureBackgroundToneProfile(imageUrl)
|
||||
: Promise.resolve(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
||||
if (!shouldRenderGlassOpticalLayer.value) {
|
||||
const [available] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
||||
recordGlassLaunchTiming('wallpaper-source-requested', imageUrl)
|
||||
if (!isGlassTheme.value) return preloadImage(imageUrl)
|
||||
|
||||
return available
|
||||
let opticalUrl = getOpticalBackgroundImage(imageUrl)
|
||||
let tone = await loadGlassWallpaperTone(opticalUrl)
|
||||
if (!tone.corsReady && isLogin.value) {
|
||||
const proxyUrl = getDisplayImageUrl(imageUrl, true)
|
||||
if (proxyUrl !== opticalUrl) {
|
||||
const proxyTone = await loadGlassWallpaperTone(proxyUrl)
|
||||
if (proxyTone.corsReady) {
|
||||
opticalUrl = proxyUrl
|
||||
tone = proxyTone
|
||||
}
|
||||
}
|
||||
}
|
||||
backgroundToneProfiles.value = {
|
||||
...backgroundToneProfiles.value,
|
||||
[imageUrl]: tone.profile,
|
||||
}
|
||||
if (tone.corsReady) {
|
||||
// DOM、tone 与 renderer 共享完全相同的像素源,避免首屏重复下载和解码。
|
||||
backgroundDisplayImages.value = {
|
||||
...backgroundDisplayImages.value,
|
||||
[imageUrl]: opticalUrl,
|
||||
}
|
||||
recordGlassLaunchTiming('wallpaper-source-ready', imageUrl)
|
||||
return true
|
||||
}
|
||||
|
||||
const opticalUrl = getOpticalBackgroundImage(imageUrl)
|
||||
const opticalReady = await preloadCorsImage(opticalUrl)
|
||||
if (!opticalReady) {
|
||||
const [displayReady] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
||||
|
||||
return displayReady
|
||||
backgroundDisplayImages.value = {
|
||||
...backgroundDisplayImages.value,
|
||||
[imageUrl]: imageUrl,
|
||||
}
|
||||
|
||||
const [displayReady] = await Promise.all([
|
||||
opticalUrl === imageUrl ? Promise.resolve(true) : preloadImage(imageUrl),
|
||||
toneProfile,
|
||||
])
|
||||
|
||||
return displayReady
|
||||
const ready = await preloadImage(imageUrl)
|
||||
recordGlassLaunchTiming(ready ? 'wallpaper-source-ready' : 'wallpaper-source-failed', imageUrl)
|
||||
return ready
|
||||
}
|
||||
|
||||
// 背景图片轮换函数
|
||||
@@ -630,20 +703,35 @@ async function rotateBackgroundImage() {
|
||||
|
||||
const nextIndex = (activeIndex + offset) % backgroundImages.value.length
|
||||
const nextImage = backgroundImages.value[nextIndex]
|
||||
const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined
|
||||
if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue
|
||||
const imagesReady = await preloadBackgroundRotationImages({
|
||||
displayUrl: nextImage,
|
||||
opticalUrl: opticalImage,
|
||||
preload: preloadImage,
|
||||
})
|
||||
if (!imagesReady) continue
|
||||
await ensureBackgroundToneProfile(nextImage)
|
||||
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
||||
let opticalRevision = 0
|
||||
try {
|
||||
if (!(await preloadBackgroundCandidate(nextImage))) continue
|
||||
const opticalImage = shouldRenderGlassOpticalLayer.value
|
||||
? getPreparedOpticalBackgroundImage(nextImage)
|
||||
: undefined
|
||||
if (opticalImage) {
|
||||
const preparation = await prepareOpticalWallpaper(opticalImage)
|
||||
opticalRevision = preparation.revision
|
||||
if (!preparation.ready) continue
|
||||
}
|
||||
const imagesReady = await preloadBackgroundRotationImages({
|
||||
displayUrl: isGlassTheme.value ? getPreparedBackgroundImage(nextImage) : nextImage,
|
||||
opticalUrl: opticalImage,
|
||||
preload: preloadImage,
|
||||
})
|
||||
if (!imagesReady) continue
|
||||
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
||||
|
||||
activateBackgroundImage(nextIndex)
|
||||
preloadNextBackgroundImage()
|
||||
return
|
||||
if (opticalImage) {
|
||||
if (!(await requestOpticalWallpaperActivation(nextIndex, opticalRevision))) continue
|
||||
} else {
|
||||
activateBackgroundImage(nextIndex)
|
||||
}
|
||||
scheduleNextBackgroundPreload()
|
||||
return
|
||||
} finally {
|
||||
if (opticalRevision > 0) cancelOpticalWallpaperTransaction(opticalRevision)
|
||||
}
|
||||
}
|
||||
|
||||
if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) {
|
||||
@@ -657,8 +745,9 @@ async function rotateBackgroundImage() {
|
||||
// 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。
|
||||
function stopBackgroundRotation() {
|
||||
backgroundRotationVersion += 1
|
||||
cancelNextBackgroundPreload()
|
||||
removeBackgroundTimer('background-rotation')
|
||||
settlePendingOpticalWallpaper(false)
|
||||
cancelOpticalWallpaperTransaction()
|
||||
}
|
||||
|
||||
function clearBackgroundRotationGrace() {
|
||||
@@ -684,7 +773,7 @@ function startBackgroundRotation() {
|
||||
stopBackgroundRotation()
|
||||
|
||||
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
|
||||
preloadNextBackgroundImage()
|
||||
scheduleNextBackgroundPreload()
|
||||
// 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。
|
||||
addBackgroundTimer(
|
||||
'background-rotation',
|
||||
@@ -784,11 +873,15 @@ async function animateAndRemoveLoader() {
|
||||
document.documentElement.style.removeProperty('overflow')
|
||||
document.body.style.removeProperty('overflow')
|
||||
completeLaunchLoading()
|
||||
recordGlassLaunchTiming('loader-removed')
|
||||
scheduleNextBackgroundPreload()
|
||||
resolve()
|
||||
}, LAUNCH_EXIT_DURATION_MS)
|
||||
})
|
||||
} else {
|
||||
completeLaunchLoading()
|
||||
recordGlassLaunchTiming('loader-removed')
|
||||
scheduleNextBackgroundPreload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,6 +953,7 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
|
||||
}
|
||||
backgroundRecoveryAttemptedVersion = -1
|
||||
resetBackgroundCrossfade()
|
||||
recordGlassLaunchTiming('wallpaper-committed', activeBackgroundImage.value)
|
||||
startBackgroundRotation()
|
||||
} catch (error: any) {
|
||||
if (loadVersion !== backgroundLoadVersion) return
|
||||
@@ -877,6 +971,28 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
|
||||
}
|
||||
}
|
||||
|
||||
// 登录前后复用同一壁纸列表和活动项;请求与主题样式并行,避免玻璃 CSS 阻塞首张壁纸准备。
|
||||
watch(
|
||||
shouldLoadBackgroundImages,
|
||||
shouldLoad => {
|
||||
stopBackgroundLoading()
|
||||
if (shouldLoad) {
|
||||
void loadBackgroundImages(backgroundLoadVersion)
|
||||
} else if (!isBackdropTheme.value) {
|
||||
backgroundImages.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(isGlassTheme, enabled => {
|
||||
if (!enabled) return
|
||||
|
||||
void Promise.all(
|
||||
renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => preloadBackgroundCandidate(layer.url)),
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// 移除URL中的时间戳参数
|
||||
const url = new URL(window.location.href)
|
||||
@@ -915,28 +1031,6 @@ onMounted(async () => {
|
||||
window.addEventListener('focus', handlePageShowThemeSync)
|
||||
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
|
||||
|
||||
// 登录前后复用同一壁纸列表和活动项,主题变化只改变呈现方式。
|
||||
watch(
|
||||
shouldLoadBackgroundImages,
|
||||
shouldLoad => {
|
||||
stopBackgroundLoading()
|
||||
if (shouldLoad) {
|
||||
void loadBackgroundImages(backgroundLoadVersion)
|
||||
} else if (!isBackdropTheme.value) {
|
||||
backgroundImages.value = []
|
||||
}
|
||||
},
|
||||
{ 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)
|
||||
@@ -1031,6 +1125,11 @@ onUnmounted(() => {
|
||||
:wallpaper-url="activeOpticalBackgroundImage"
|
||||
:previous-wallpaper-url="previousOpticalBackgroundImage"
|
||||
:pending-wallpaper-url="pendingOpticalBackgroundImage"
|
||||
:pending-wallpaper-revision="pendingOpticalWallpaperRevision"
|
||||
:activate-wallpaper-revision="activateOpticalWallpaperRevision"
|
||||
@wallpaper-activation-failed="handleOpticalWallpaperActivationFailed"
|
||||
@wallpaper-activated="handleOpticalWallpaperActivated"
|
||||
@wallpaper-preparation-failed="handleOpticalWallpaperPreparationFailed"
|
||||
@wallpaper-prepared="handleOpticalWallpaperPrepared"
|
||||
/>
|
||||
<!-- 页面内容 -->
|
||||
|
||||
@@ -11,10 +11,15 @@ import {
|
||||
GLASS_OPTICAL_STRENGTH_MAX,
|
||||
GLASS_OPTICAL_STRENGTH_MIN,
|
||||
getAvailableGlassOpticalPresets,
|
||||
getGlassOpticalPresetKey,
|
||||
getGlassOpticalPresetParameters,
|
||||
getGlassOpticalPresetParametersWithOverrides,
|
||||
normalizeGlassOpticalStrength,
|
||||
type GlassOpticalParameters,
|
||||
type GlassOpticalPreset,
|
||||
type GlassOpticalPresetOverrides,
|
||||
} from '@/utils/glassOptics'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -32,11 +37,13 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const { settings } = useThemeCustomizer()
|
||||
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
||||
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
||||
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
||||
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
|
||||
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
|
||||
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
|
||||
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
|
||||
@@ -68,6 +75,7 @@ watch(
|
||||
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
||||
draftFlowStrength.value = settings.value.glassFlowStrength
|
||||
draftPreset.value = settings.value.glassPreset
|
||||
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
|
||||
draftQuality.value = settings.value.glassQuality
|
||||
draftReflectionStrength.value = settings.value.glassReflectionStrength
|
||||
draftTransmissionStrength.value = settings.value.glassTransmissionStrength
|
||||
@@ -112,7 +120,7 @@ function updateAppearance(value: unknown) {
|
||||
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
|
||||
|
||||
draftAppearance.value = value
|
||||
previewGlassSettings({ glassAppearance: value })
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
/** 仅允许面板声明的质量档位进入待保存设置。 */
|
||||
@@ -121,15 +129,18 @@ function updateQuality(value: unknown) {
|
||||
if (!option) return
|
||||
|
||||
draftQuality.value = option.value
|
||||
previewGlassSettings({ glassQuality: option.value })
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
/** 将六个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
|
||||
/** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */
|
||||
function previewDraftParameters() {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassPresetOverrides: draftPresetOverrides.value,
|
||||
glassQuality: draftQuality.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||
glassTranslationStrength: draftTranslationStrength.value,
|
||||
@@ -137,13 +148,29 @@ function previewDraftParameters() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */
|
||||
/** 读取草稿中当前显示的六参数。 */
|
||||
function getDraftParameters(): GlassOpticalParameters {
|
||||
return {
|
||||
deformation: draftDeformationStrength.value,
|
||||
flow: draftFlowStrength.value,
|
||||
reflection: draftReflectionStrength.value,
|
||||
transmission: draftTransmissionStrength.value,
|
||||
translation: draftTranslationStrength.value,
|
||||
transparency: draftTransparencyStrength.value,
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换方案时优先恢复该组合的草稿覆盖,没有覆盖才使用矩阵。 */
|
||||
function applyPreset(value: unknown) {
|
||||
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
|
||||
if (!availablePresets.value.includes(value)) return
|
||||
|
||||
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value)
|
||||
draftPreset.value = value
|
||||
const effectivePreset = availablePresets.value.includes(value) ? value : 'natural'
|
||||
const parameters = getGlassOpticalPresetParametersWithOverrides(
|
||||
draftAppearance.value,
|
||||
draftQuality.value,
|
||||
effectivePreset,
|
||||
draftPresetOverrides.value,
|
||||
)
|
||||
draftPreset.value = effectivePreset
|
||||
draftDeformationStrength.value = parameters.deformation
|
||||
draftFlowStrength.value = parameters.flow
|
||||
draftReflectionStrength.value = parameters.reflection
|
||||
@@ -153,45 +180,66 @@ function applyPreset(value: unknown) {
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 用调整后的当前六参数覆盖当前材质、质量与方案组合。 */
|
||||
function updateDraftPresetOverride() {
|
||||
const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
|
||||
draftPresetOverrides.value = {
|
||||
...draftPresetOverrides.value,
|
||||
[key]: getDraftParameters(),
|
||||
}
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 将采样平移限制为 renderer 支持的稳定范围。 */
|
||||
function updateTranslationStrength(value: unknown) {
|
||||
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
|
||||
function updateDeformationStrength(value: unknown) {
|
||||
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
|
||||
function updateFlowStrength(value: unknown) {
|
||||
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
|
||||
function updateReflectionStrength(value: unknown) {
|
||||
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
|
||||
function updateTransmissionStrength(value: unknown) {
|
||||
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
|
||||
function updateTransparencyStrength(value: unknown) {
|
||||
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */
|
||||
/** 删除当前组合覆盖并恢复该方案矩阵,不影响其他组合。 */
|
||||
function resetSettings() {
|
||||
applyPreset(activePreset.value)
|
||||
const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
|
||||
const nextOverrides = { ...draftPresetOverrides.value }
|
||||
delete nextOverrides[key]
|
||||
draftPresetOverrides.value = nextOverrides
|
||||
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, draftPreset.value)
|
||||
draftDeformationStrength.value = parameters.deformation
|
||||
draftFlowStrength.value = parameters.flow
|
||||
draftReflectionStrength.value = parameters.reflection
|
||||
draftTransmissionStrength.value = parameters.transmission
|
||||
draftTranslationStrength.value = parameters.translation
|
||||
draftTransparencyStrength.value = parameters.transparency
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
|
||||
@@ -206,6 +254,7 @@ async function saveSettings() {
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassPresetOverrides: draftPresetOverrides.value,
|
||||
glassQuality: draftQuality.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||
@@ -224,7 +273,14 @@ onScopeDispose(cancelGlassPreview)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="100%" max-width="30rem" scrollable>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
width="100%"
|
||||
max-width="30rem"
|
||||
scrollable
|
||||
:fullscreen="display.smAndDown.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -415,14 +471,21 @@ onScopeDispose(cancelGlassPreview)
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
|
||||
<VCardActions class="glass-settings-dialog__actions justify-center">
|
||||
<VBtn :slim="false" variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
|
||||
{{ t('common.reset') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" :loading="isSaving" @click="saveSettings">
|
||||
<VBtn
|
||||
:slim="false"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
prepend-icon="mdi-content-save"
|
||||
:loading="isSaving"
|
||||
@click="saveSettings"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -436,6 +499,11 @@ onScopeDispose(cancelGlassPreview)
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__actions {
|
||||
flex: none;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__label {
|
||||
margin: 0 0 10px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
|
||||
@@ -173,12 +173,7 @@ onBeforeMount(async () => {
|
||||
<template>
|
||||
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VCard
|
||||
v-if="renderMode === 'vuetify'"
|
||||
:title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"
|
||||
data-glass-optical-surface
|
||||
data-glass-optical-mode="static-material"
|
||||
>
|
||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
@@ -213,7 +208,7 @@ onBeforeMount(async () => {
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- Vue 渲染模式 -->
|
||||
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
|
||||
<VCard v-else-if="renderMode === 'vue'">
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
|
||||
@@ -136,12 +136,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VCard
|
||||
v-if="renderMode === 'vuetify'"
|
||||
:title="`${props.plugin?.plugin_name}`"
|
||||
data-glass-optical-surface
|
||||
data-glass-optical-mode="static-material"
|
||||
>
|
||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<VCardText v-else class="min-h-40">
|
||||
@@ -163,7 +158,7 @@ onMounted(() => {
|
||||
/>
|
||||
</VCard>
|
||||
<!-- Vue 渲染模式 -->
|
||||
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
|
||||
<VCard v-else-if="renderMode === 'vue'">
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
|
||||
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
|
||||
|
||||
const slotStub = { template: '<div><slot /></div>' }
|
||||
const dialogStub = {
|
||||
props: ['fullscreen'],
|
||||
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
|
||||
}
|
||||
const toggleStub = {
|
||||
props: ['modelValue'],
|
||||
template: '<div :data-model-value="modelValue"><slot /></div>',
|
||||
@@ -25,6 +29,7 @@ const mocks = vi.hoisted(() => ({
|
||||
glassDeformationStrength: 50,
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 50,
|
||||
glassTransmissionStrength: 50,
|
||||
@@ -47,6 +52,10 @@ vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', () => ({
|
||||
useDisplay: () => ({ smAndDown: { value: true } }),
|
||||
}))
|
||||
|
||||
describe('GlassSettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.cancelGlassPreview.mockClear()
|
||||
@@ -56,6 +65,7 @@ describe('GlassSettingsDialog', () => {
|
||||
mocks.settings.value.glassDeformationStrength = 50
|
||||
mocks.settings.value.glassFlowStrength = 50
|
||||
mocks.settings.value.glassPreset = 'natural'
|
||||
mocks.settings.value.glassPresetOverrides = {}
|
||||
mocks.settings.value.glassQuality = 'css'
|
||||
mocks.settings.value.glassReflectionStrength = 50
|
||||
mocks.settings.value.glassTransmissionStrength = 50
|
||||
@@ -68,8 +78,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -81,6 +92,8 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(sliders).toHaveLength(3)
|
||||
expect(sliders[0].attributes('data-disabled')).toBe('undefined')
|
||||
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
|
||||
expect(wrapper.find('.dialog-stub').attributes('data-fullscreen')).toBe('true')
|
||||
expect(wrapper.find('.glass-settings-dialog__actions').classes()).toContain('justify-center')
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
|
||||
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
|
||||
@@ -96,17 +109,37 @@ describe('GlassSettingsDialog', () => {
|
||||
mocks.settings.value.glassTransmissionStrength = 57
|
||||
mocks.settings.value.glassTranslationStrength = 57
|
||||
mocks.settings.value.glassTransparencyStrength = 55
|
||||
mocks.settings.value.glassPresetOverrides = {
|
||||
'clear:balanced:glide': {
|
||||
deformation: 24,
|
||||
flow: 35,
|
||||
reflection: 46,
|
||||
transmission: 57,
|
||||
translation: 68,
|
||||
transparency: 79,
|
||||
},
|
||||
'frosted:high:liquid': {
|
||||
deformation: 65,
|
||||
flow: 61,
|
||||
reflection: 58,
|
||||
transmission: 57,
|
||||
translation: 57,
|
||||
transparency: 55,
|
||||
},
|
||||
}
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtn: {
|
||||
emits: ['click'],
|
||||
props: ['prependIcon'],
|
||||
template: '<button :data-icon="prependIcon" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['prependIcon', 'slim', 'variant'],
|
||||
template:
|
||||
'<button :data-icon="prependIcon" :data-slim="String(slim)" :data-variant="variant" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VDivider: true,
|
||||
VSlider: sliderStub,
|
||||
@@ -115,17 +148,33 @@ describe('GlassSettingsDialog', () => {
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const resetButton = wrapper.find('[data-icon="mdi-refresh"]')
|
||||
const saveButton = wrapper.find('[data-icon="mdi-content-save"]')
|
||||
|
||||
expect(resetButton.attributes('data-slim')).toBe('false')
|
||||
expect(saveButton.attributes('data-slim')).toBe('false')
|
||||
expect(saveButton.attributes('data-variant')).toBe('elevated')
|
||||
await resetButton.trigger('click')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassDeformationStrength: 82,
|
||||
glassFlowStrength: 80,
|
||||
glassAppearance: 'frosted',
|
||||
glassDeformationStrength: 66,
|
||||
glassFlowStrength: 64,
|
||||
glassPreset: 'liquid',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:glide': {
|
||||
deformation: 24,
|
||||
flow: 35,
|
||||
reflection: 46,
|
||||
transmission: 57,
|
||||
translation: 68,
|
||||
transparency: 79,
|
||||
},
|
||||
},
|
||||
glassQuality: 'high',
|
||||
glassReflectionStrength: 31,
|
||||
glassTransmissionStrength: 58,
|
||||
glassTranslationStrength: 54,
|
||||
glassTransparencyStrength: 46,
|
||||
glassTransmissionStrength: 47,
|
||||
glassTranslationStrength: 43,
|
||||
glassTransparencyStrength: 32,
|
||||
})
|
||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -135,8 +184,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -149,7 +199,27 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(sliders[2].attributes('data-disabled')).toBe('undefined')
|
||||
await sliders[2].setValue('86')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassReflectionStrength: 86 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 50,
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'clear:css:natural': {
|
||||
deformation: 50,
|
||||
flow: 50,
|
||||
reflection: 86,
|
||||
transmission: 50,
|
||||
translation: 50,
|
||||
transparency: 50,
|
||||
},
|
||||
},
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 86,
|
||||
glassTransmissionStrength: 50,
|
||||
glassTranslationStrength: 50,
|
||||
glassTransparencyStrength: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('hides preset choices in standard quality', () => {
|
||||
@@ -157,8 +227,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -170,7 +241,7 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the selected preset highlighted after slider adjustments', async () => {
|
||||
it('keeps the selected preset highlighted and records its combination override', async () => {
|
||||
mocks.settings.value.glassQuality = 'balanced'
|
||||
mocks.settings.value.glassPreset = 'glide'
|
||||
mocks.settings.value.glassTransparencyStrength = 61
|
||||
@@ -178,10 +249,11 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtn: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -199,6 +271,21 @@ describe('GlassSettingsDialog', () => {
|
||||
await sliders[0].setValue('77')
|
||||
|
||||
expect(preset.attributes('data-model-value')).toBe('glide')
|
||||
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
glassPreset: 'glide',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:glide': {
|
||||
deformation: 50,
|
||||
flow: 50,
|
||||
reflection: 50,
|
||||
transmission: 50,
|
||||
translation: 50,
|
||||
transparency: 77,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes and previews all six independent slider values', async () => {
|
||||
@@ -207,8 +294,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -227,12 +315,28 @@ describe('GlassSettingsDialog', () => {
|
||||
await sliders[4].setValue('68.7')
|
||||
await sliders[5].setValue('62.2')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassTransmissionStrength: 78 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassReflectionStrength: 74 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassTranslationStrength: 84 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassDeformationStrength: 69 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(6, { glassFlowStrength: 62 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledTimes(6)
|
||||
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 69,
|
||||
glassFlowStrength: 62,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:natural': {
|
||||
deformation: 69,
|
||||
flow: 62,
|
||||
reflection: 74,
|
||||
transmission: 78,
|
||||
translation: 84,
|
||||
transparency: 91,
|
||||
},
|
||||
},
|
||||
glassQuality: 'balanced',
|
||||
glassReflectionStrength: 74,
|
||||
glassTransmissionStrength: 78,
|
||||
glassTranslationStrength: 84,
|
||||
glassTransparencyStrength: 91,
|
||||
})
|
||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -242,8 +346,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
||||
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||
import {
|
||||
createGlassWallpaperSourceCache,
|
||||
getGlassWallpaperPreparationKey,
|
||||
setGlassRendererState,
|
||||
useGlassOpticalInteractionSource,
|
||||
useGlassOpticalRenderer,
|
||||
@@ -39,16 +41,40 @@ const props = defineProps<{
|
||||
previousWallpaperUrl: string
|
||||
/** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */
|
||||
pendingWallpaperUrl?: string
|
||||
/** 单调递增的壁纸准备事务版本;相同 URL 的旧回执不得完成新事务。 */
|
||||
pendingWallpaperRevision?: number
|
||||
/** 父层已完成可见图片预载,允许两个 context 在同一绘制帧提交该 revision。 */
|
||||
activateWallpaperRevision?: number
|
||||
}>()
|
||||
|
||||
const timingWindow = window as typeof window & {
|
||||
__glassPerformanceProbeEnabled?: boolean
|
||||
__glassLaunchTimings?: Array<{ detail?: string; stage: string; time: number }>
|
||||
}
|
||||
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
|
||||
timingWindow.__glassLaunchTimings ??= []
|
||||
timingWindow.__glassLaunchTimings.push({
|
||||
stage: 'optical-layer-setup',
|
||||
time: performance.now(),
|
||||
})
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** fixed 与 scroll renderer 均已准备同一张待切换纹理。 */
|
||||
wallpaperPrepared: [url: string]
|
||||
wallpaperPrepared: [url: string, revision: number]
|
||||
/** 任一 context 无法准备当前 revision;父层必须立即取消整笔事务。 */
|
||||
wallpaperPreparationFailed: [url: string, revision: number]
|
||||
/** fixed 与 scroll renderer 均已消费 prepared 纹理并切到活动槽。 */
|
||||
wallpaperActivated: [url: string, revision: number, startedAt: number]
|
||||
/** 任一 context 提交失败;父层必须取消该 revision。 */
|
||||
wallpaperActivationFailed: [url: string, revision: number]
|
||||
}>()
|
||||
|
||||
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const interactionSource = useGlassOpticalInteractionSource()
|
||||
const pagePresentationMotion = usePagePresentationMotion()
|
||||
const wallpaperSourceCache = createGlassWallpaperSourceCache()
|
||||
const fixedRenderer = useGlassOpticalRenderer({
|
||||
active: true,
|
||||
appearance: () => props.appearance,
|
||||
@@ -66,8 +92,10 @@ const fixedRenderer = useGlassOpticalRenderer({
|
||||
transitionDuration: () => props.transitionDuration,
|
||||
transitionStartedAt: () => props.transitionStartedAt,
|
||||
wallpaperUrl: () => props.wallpaperUrl,
|
||||
wallpaperSourceCache,
|
||||
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
||||
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
|
||||
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
|
||||
surfaceSpace: 'fixed',
|
||||
syncDocumentState: false,
|
||||
})
|
||||
@@ -89,8 +117,10 @@ const scrollRenderer = useGlassOpticalRenderer({
|
||||
transitionDuration: () => props.transitionDuration,
|
||||
transitionStartedAt: () => props.transitionStartedAt,
|
||||
wallpaperUrl: () => props.wallpaperUrl,
|
||||
wallpaperSourceCache,
|
||||
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
||||
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
|
||||
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
|
||||
surfaceSpace: 'scroll',
|
||||
syncDocumentState: false,
|
||||
})
|
||||
@@ -107,16 +137,151 @@ watchEffect(() => {
|
||||
: 'fallback'
|
||||
|
||||
setGlassRendererState(rendererState, state)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const url = props.pendingWallpaperUrl
|
||||
if (url && fixedRenderer.preparedWallpaperUrl.value === url && scrollRenderer.preparedWallpaperUrl.value === url) {
|
||||
emit('wallpaperPrepared', url)
|
||||
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
|
||||
timingWindow.__glassLaunchTimings?.push({
|
||||
detail: state,
|
||||
stage: 'optical-layer-state',
|
||||
time: performance.now(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let lastPreparedAcknowledgement = ''
|
||||
watchEffect(() => {
|
||||
const url = props.pendingWallpaperUrl
|
||||
const revision = props.pendingWallpaperRevision ?? 0
|
||||
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url ?? '')
|
||||
const acknowledgement = `${revision}:${preparationKey}:${url}`
|
||||
const prepared =
|
||||
Boolean(url) &&
|
||||
revision > 0 &&
|
||||
fixedRenderer.state.value === 'ready' &&
|
||||
scrollRenderer.state.value === 'ready' &&
|
||||
fixedRenderer.preparedWallpaperUrl.value === url &&
|
||||
fixedRenderer.preparedWallpaperRevision.value === revision &&
|
||||
fixedRenderer.preparedWallpaperPreparationKey.value === preparationKey &&
|
||||
scrollRenderer.preparedWallpaperUrl.value === url &&
|
||||
scrollRenderer.preparedWallpaperRevision.value === revision &&
|
||||
scrollRenderer.preparedWallpaperPreparationKey.value === preparationKey
|
||||
if (prepared && acknowledgement !== lastPreparedAcknowledgement) {
|
||||
lastPreparedAcknowledgement = acknowledgement
|
||||
emit('wallpaperPrepared', url, revision)
|
||||
}
|
||||
})
|
||||
|
||||
let lastPreparationFailedAcknowledgement = ''
|
||||
watchEffect(() => {
|
||||
const url = props.pendingWallpaperUrl ?? ''
|
||||
const revision = props.pendingWallpaperRevision ?? 0
|
||||
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url)
|
||||
const acknowledgement = `${revision}:${preparationKey}:${url}`
|
||||
const failed = [fixedRenderer, scrollRenderer].some(
|
||||
renderer =>
|
||||
renderer.failedWallpaperUrl.value === url &&
|
||||
renderer.failedWallpaperRevision.value === revision &&
|
||||
renderer.failedWallpaperPreparationKey.value === preparationKey,
|
||||
)
|
||||
if (url && revision > 0 && failed && acknowledgement !== lastPreparationFailedAcknowledgement) {
|
||||
lastPreparationFailedAcknowledgement = acknowledgement
|
||||
emit('wallpaperPreparationFailed', url, revision)
|
||||
}
|
||||
})
|
||||
|
||||
let activationFrame: number | null = null
|
||||
let scheduledActivation = ''
|
||||
let lastActivatedAcknowledgement = ''
|
||||
let lastFailedAcknowledgement = ''
|
||||
|
||||
function rollbackWallpaperActivation(url: string, revision: number) {
|
||||
for (const renderer of [fixedRenderer, scrollRenderer]) {
|
||||
try {
|
||||
renderer.rollbackPreparedWallpaperActivation(url, revision)
|
||||
} catch {
|
||||
// 两个 context 独立回滚;一个异常不得阻止另一个恢复并通知父层取消。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const url = props.pendingWallpaperUrl
|
||||
const revision = props.pendingWallpaperRevision ?? 0
|
||||
const activationRevision = props.activateWallpaperRevision ?? 0
|
||||
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url ?? '')
|
||||
const acknowledgement = `${revision}:${preparationKey}:${url}`
|
||||
const canActivate =
|
||||
Boolean(url) &&
|
||||
revision > 0 &&
|
||||
activationRevision === revision &&
|
||||
fixedRenderer.state.value === 'ready' &&
|
||||
scrollRenderer.state.value === 'ready' &&
|
||||
fixedRenderer.preparedWallpaperUrl.value === url &&
|
||||
fixedRenderer.preparedWallpaperRevision.value === revision &&
|
||||
fixedRenderer.preparedWallpaperPreparationKey.value === preparationKey &&
|
||||
scrollRenderer.preparedWallpaperUrl.value === url &&
|
||||
scrollRenderer.preparedWallpaperRevision.value === revision &&
|
||||
scrollRenderer.preparedWallpaperPreparationKey.value === preparationKey
|
||||
if (
|
||||
!canActivate ||
|
||||
acknowledgement === lastActivatedAcknowledgement ||
|
||||
acknowledgement === lastFailedAcknowledgement ||
|
||||
acknowledgement === scheduledActivation
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
|
||||
scheduledActivation = acknowledgement
|
||||
activationFrame = requestAnimationFrame(startedAt => {
|
||||
activationFrame = null
|
||||
scheduledActivation = ''
|
||||
const currentUrl = props.pendingWallpaperUrl ?? ''
|
||||
const currentRevision = props.pendingWallpaperRevision ?? 0
|
||||
const currentPreparationKey = getGlassWallpaperPreparationKey(
|
||||
props.appearance,
|
||||
props.quality,
|
||||
props.routeKey,
|
||||
currentUrl,
|
||||
)
|
||||
const currentAcknowledgement = `${currentRevision}:${currentPreparationKey}:${currentUrl}`
|
||||
if (
|
||||
currentAcknowledgement !== acknowledgement ||
|
||||
props.activateWallpaperRevision !== currentRevision ||
|
||||
!fixedRenderer.canActivatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey) ||
|
||||
!scrollRenderer.canActivatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fixedActivated = fixedRenderer.activatePreparedWallpaper(
|
||||
currentUrl,
|
||||
currentRevision,
|
||||
currentPreparationKey,
|
||||
startedAt,
|
||||
)
|
||||
const scrollActivated =
|
||||
fixedActivated &&
|
||||
scrollRenderer.activatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey, startedAt)
|
||||
if (!fixedActivated || !scrollActivated) {
|
||||
rollbackWallpaperActivation(currentUrl, currentRevision)
|
||||
lastFailedAcknowledgement = acknowledgement
|
||||
emit('wallpaperActivationFailed', currentUrl, currentRevision)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
rollbackWallpaperActivation(currentUrl, currentRevision)
|
||||
lastFailedAcknowledgement = acknowledgement
|
||||
emit('wallpaperActivationFailed', currentUrl, currentRevision)
|
||||
return
|
||||
}
|
||||
|
||||
lastActivatedAcknowledgement = acknowledgement
|
||||
emit('wallpaperActivated', currentUrl, currentRevision, startedAt)
|
||||
})
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
|
||||
setGlassRendererState(rendererState, 'fallback')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
|
||||
|
||||
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||
const rendererResults = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
activatePreparedWallpaper: ReturnType<typeof vi.fn>
|
||||
activeWallpaperPreparationKey: { value: string }
|
||||
activeWallpaperRevision: { value: number }
|
||||
activeWallpaperUrl: { value: string }
|
||||
canActivatePreparedWallpaper: ReturnType<typeof vi.fn>
|
||||
failedWallpaperPreparationKey: { value: string }
|
||||
failedWallpaperRevision: { value: number }
|
||||
failedWallpaperUrl: { value: string }
|
||||
preparedWallpaperPreparationKey: { value: string }
|
||||
preparedWallpaperRevision: { value: number }
|
||||
preparedWallpaperUrl: { value: string }
|
||||
renderedFrames: { value: number }
|
||||
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
|
||||
state: { value: string }
|
||||
}>,
|
||||
)
|
||||
const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() }))
|
||||
const wallpaperSourceCache = vi.hoisted(() => ({ get: vi.fn() }))
|
||||
const setRendererState = vi.hoisted(() =>
|
||||
vi.fn((state: { value: string }, value: string) => {
|
||||
state.value = value
|
||||
@@ -12,21 +32,82 @@ const setRendererState = vi.hoisted(() =>
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useGlassOpticalRenderer', () => ({
|
||||
createGlassWallpaperSourceCache: vi.fn(() => wallpaperSourceCache),
|
||||
getGlassWallpaperPreparationKey: vi.fn(
|
||||
(appearance: string, quality: string, routeKey: string, url: string) =>
|
||||
`${appearance}:${quality}:${routeKey}:${url}`,
|
||||
),
|
||||
setGlassRendererState: setRendererState,
|
||||
useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
|
||||
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
|
||||
rendererCalls.push(options)
|
||||
let rollbackState = {
|
||||
preparationKey: '',
|
||||
revision: 0,
|
||||
url: '',
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
activeWallpaperPreparationKey: ref(''),
|
||||
activeWallpaperRevision: ref(0),
|
||||
activeWallpaperUrl: ref(''),
|
||||
failedWallpaperPreparationKey: ref(''),
|
||||
failedWallpaperRevision: ref(0),
|
||||
failedWallpaperUrl: ref(''),
|
||||
preparedWallpaperPreparationKey: ref(''),
|
||||
preparedWallpaperRevision: ref(0),
|
||||
preparedWallpaperUrl: ref(''),
|
||||
renderedFrames: ref(0),
|
||||
state: ref('ready'),
|
||||
canActivatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
|
||||
return (
|
||||
result.state.value === 'ready' &&
|
||||
result.preparedWallpaperUrl.value === url &&
|
||||
result.preparedWallpaperRevision.value === revision &&
|
||||
result.preparedWallpaperPreparationKey.value === preparationKey
|
||||
)
|
||||
}),
|
||||
activatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
|
||||
if (!result.canActivatePreparedWallpaper(url, revision, preparationKey)) return false
|
||||
|
||||
rollbackState = {
|
||||
preparationKey: result.activeWallpaperPreparationKey.value,
|
||||
revision: result.activeWallpaperRevision.value,
|
||||
url: result.activeWallpaperUrl.value,
|
||||
}
|
||||
result.preparedWallpaperUrl.value = ''
|
||||
result.preparedWallpaperRevision.value = 0
|
||||
result.preparedWallpaperPreparationKey.value = ''
|
||||
result.activeWallpaperUrl.value = url
|
||||
result.activeWallpaperRevision.value = revision
|
||||
result.activeWallpaperPreparationKey.value = preparationKey
|
||||
|
||||
return true
|
||||
}),
|
||||
rollbackPreparedWallpaperActivation: vi.fn((url: string, revision: number) => {
|
||||
if (result.activeWallpaperUrl.value !== url || result.activeWallpaperRevision.value !== revision) return false
|
||||
|
||||
result.activeWallpaperUrl.value = rollbackState.url
|
||||
result.activeWallpaperRevision.value = rollbackState.revision
|
||||
result.activeWallpaperPreparationKey.value = rollbackState.preparationKey
|
||||
|
||||
return true
|
||||
}),
|
||||
}
|
||||
rendererResults.push(result)
|
||||
|
||||
return result
|
||||
}),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('GlassOpticalLayer', () => {
|
||||
it('uses exactly two visible presentation contexts with one interaction source', () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
setRendererState.mockClear()
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
@@ -52,6 +133,7 @@ describe('GlassOpticalLayer', () => {
|
||||
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
||||
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
|
||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
||||
expect(rendererCalls[0].pageMotion).toBeUndefined()
|
||||
expect(rendererCalls[1].pageMotion).toEqual(
|
||||
@@ -64,4 +146,241 @@ describe('GlassOpticalLayer', () => {
|
||||
wrapper.unmount()
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||
})
|
||||
|
||||
it('activates matching resource bundles in one shared animation frame', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
let activationCallback: FrameRequestCallback | null = null
|
||||
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
activationCallback = callback
|
||||
|
||||
return 31
|
||||
})
|
||||
vi.stubGlobal('requestAnimationFrame', requestFrame)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'frosted',
|
||||
activateWallpaperRevision: 0,
|
||||
deformationStrength: 50,
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 7,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper-current.jpg',
|
||||
},
|
||||
})
|
||||
const [fixedRenderer, scrollRenderer] = rendererResults
|
||||
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
|
||||
|
||||
fixedRenderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
|
||||
fixedRenderer.preparedWallpaperRevision.value = 7
|
||||
fixedRenderer.preparedWallpaperPreparationKey.value = preparationKey
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('wallpaperPrepared')).toBeUndefined()
|
||||
|
||||
scrollRenderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
|
||||
scrollRenderer.preparedWallpaperRevision.value = 7
|
||||
scrollRenderer.preparedWallpaperPreparationKey.value = 'stale-key'
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('wallpaperPrepared')).toBeUndefined()
|
||||
|
||||
scrollRenderer.preparedWallpaperPreparationKey.value = preparationKey
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('wallpaperPrepared')).toEqual([['/wallpaper-next.jpg', 7]])
|
||||
|
||||
await wrapper.setProps({ activateWallpaperRevision: 7 })
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
|
||||
expect(requestFrame).toHaveBeenCalledOnce()
|
||||
|
||||
;(activationCallback as FrameRequestCallback | null)?.(420)
|
||||
await nextTick()
|
||||
expect(fixedRenderer.activatePreparedWallpaper).toHaveBeenCalledWith('/wallpaper-next.jpg', 7, preparationKey, 420)
|
||||
expect(scrollRenderer.activatePreparedWallpaper).toHaveBeenCalledWith('/wallpaper-next.jpg', 7, preparationKey, 420)
|
||||
expect(wrapper.emitted('wallpaperActivated')).toEqual([['/wallpaper-next.jpg', 7, 420]])
|
||||
expect(fixedRenderer.activeWallpaperRevision.value).toBe(7)
|
||||
expect(scrollRenderer.activeWallpaperRevision.value).toBe(7)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reports one matching preparation failure so the parent can cancel the pending revision', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'frosted',
|
||||
deformationStrength: 50,
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 10,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper-current.jpg',
|
||||
},
|
||||
})
|
||||
const [fixedRenderer, scrollRenderer] = rendererResults
|
||||
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
|
||||
|
||||
fixedRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
|
||||
fixedRenderer.failedWallpaperRevision.value = 9
|
||||
fixedRenderer.failedWallpaperPreparationKey.value = preparationKey
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('wallpaperPreparationFailed')).toBeUndefined()
|
||||
|
||||
scrollRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
|
||||
scrollRenderer.failedWallpaperRevision.value = 10
|
||||
scrollRenderer.failedWallpaperPreparationKey.value = preparationKey
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('wallpaperPreparationFailed')).toEqual([['/wallpaper-next.jpg', 10]])
|
||||
|
||||
fixedRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
|
||||
fixedRenderer.failedWallpaperRevision.value = 10
|
||||
fixedRenderer.failedWallpaperPreparationKey.value = preparationKey
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('wallpaperPreparationFailed')).toEqual([['/wallpaper-next.jpg', 10]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not partially commit while either context is unavailable', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
let activationCallback: FrameRequestCallback | null = null
|
||||
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
activationCallback = callback
|
||||
|
||||
return 32
|
||||
})
|
||||
vi.stubGlobal('requestAnimationFrame', requestFrame)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'frosted',
|
||||
activateWallpaperRevision: 8,
|
||||
deformationStrength: 50,
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 8,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'high',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper-current.jpg',
|
||||
},
|
||||
})
|
||||
const [fixedRenderer, scrollRenderer] = rendererResults
|
||||
const preparationKey = 'frosted:high:/dashboard:/wallpaper-next.jpg'
|
||||
for (const renderer of rendererResults) {
|
||||
renderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
|
||||
renderer.preparedWallpaperRevision.value = 8
|
||||
renderer.preparedWallpaperPreparationKey.value = preparationKey
|
||||
}
|
||||
scrollRenderer.state.value = 'fallback'
|
||||
await nextTick()
|
||||
|
||||
expect(requestFrame).not.toHaveBeenCalled()
|
||||
expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
||||
expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
|
||||
|
||||
scrollRenderer.state.value = 'ready'
|
||||
await nextTick()
|
||||
expect(requestFrame).toHaveBeenCalledOnce()
|
||||
;(activationCallback as FrameRequestCallback | null)?.(640)
|
||||
await nextTick()
|
||||
|
||||
expect(fixedRenderer.activatePreparedWallpaper).toHaveBeenCalledOnce()
|
||||
expect(scrollRenderer.activatePreparedWallpaper).toHaveBeenCalledOnce()
|
||||
expect(wrapper.emitted('wallpaperActivated')).toEqual([['/wallpaper-next.jpg', 8, 640]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'returns false',
|
||||
(renderer: (typeof rendererResults)[number]) => renderer.activatePreparedWallpaper.mockReturnValueOnce(false),
|
||||
],
|
||||
[
|
||||
'throws',
|
||||
(renderer: (typeof rendererResults)[number]) =>
|
||||
renderer.activatePreparedWallpaper.mockImplementationOnce(() => {
|
||||
throw new Error('context commit failed')
|
||||
}),
|
||||
],
|
||||
])('rolls both contexts back when the second activation %s', async (_, failScrollActivation) => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
let activationCallback: FrameRequestCallback | null = null
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
activationCallback = callback
|
||||
|
||||
return 33
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'frosted',
|
||||
activateWallpaperRevision: 9,
|
||||
deformationStrength: 50,
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 9,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper-current.jpg',
|
||||
},
|
||||
})
|
||||
const [fixedRenderer, scrollRenderer] = rendererResults
|
||||
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
|
||||
for (const renderer of rendererResults) {
|
||||
renderer.activeWallpaperUrl.value = '/wallpaper-current.jpg'
|
||||
renderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
|
||||
renderer.preparedWallpaperRevision.value = 9
|
||||
renderer.preparedWallpaperPreparationKey.value = preparationKey
|
||||
}
|
||||
failScrollActivation(scrollRenderer)
|
||||
await nextTick()
|
||||
|
||||
;(activationCallback as FrameRequestCallback | null)?.(720)
|
||||
await nextTick()
|
||||
|
||||
expect(fixedRenderer.rollbackPreparedWallpaperActivation).toHaveBeenCalledWith('/wallpaper-next.jpg', 9)
|
||||
expect(scrollRenderer.rollbackPreparedWallpaperActivation).toHaveBeenCalledWith('/wallpaper-next.jpg', 9)
|
||||
expect(fixedRenderer.activeWallpaperUrl.value).toBe('/wallpaper-current.jpg')
|
||||
expect(scrollRenderer.activeWallpaperUrl.value).toBe('/wallpaper-current.jpg')
|
||||
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
|
||||
expect(wrapper.emitted('wallpaperActivationFailed')).toEqual([['/wallpaper-next.jpg', 9]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
134
src/composables/__tests__/useGlassWallpaperTransaction.spec.ts
Normal file
134
src/composables/__tests__/useGlassWallpaperTransaction.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { effectScope } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useGlassWallpaperTransaction', () => {
|
||||
it('retains the request after prepared and retires it only after active', async () => {
|
||||
const scope = effectScope()
|
||||
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
|
||||
const prepared = transaction.requestPreparation('/next.jpg')
|
||||
const revision = transaction.requestedRevision.value
|
||||
|
||||
expect(transaction.acknowledgePrepared('/next.jpg', revision)).toBe(true)
|
||||
await expect(prepared).resolves.toBe(true)
|
||||
expect(transaction.requestedUrl.value).toBe('/next.jpg')
|
||||
expect(transaction.requestedRevision.value).toBe(revision)
|
||||
|
||||
const activated = transaction.requestActivation(4)
|
||||
const result = transaction.acknowledgeActivated('/next.jpg', revision, 420)
|
||||
|
||||
expect(result).toEqual({ payload: 4, startedAt: 420 })
|
||||
await expect(activated).resolves.toBe(true)
|
||||
expect(transaction.requestedUrl.value).toBe('')
|
||||
expect(transaction.requestedRevision.value).toBe(0)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('ignores late prepared and active acknowledgements after a newer request', async () => {
|
||||
const scope = effectScope()
|
||||
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
|
||||
const firstPrepared = transaction.requestPreparation('/a.jpg')
|
||||
const firstRevision = transaction.requestedRevision.value
|
||||
const secondPrepared = transaction.requestPreparation('/b.jpg')
|
||||
const secondRevision = transaction.requestedRevision.value
|
||||
|
||||
await expect(firstPrepared).resolves.toBe(false)
|
||||
expect(transaction.acknowledgePrepared('/a.jpg', firstRevision)).toBe(false)
|
||||
expect(transaction.acknowledgeActivated('/a.jpg', firstRevision, 100)).toBeNull()
|
||||
expect(transaction.requestedUrl.value).toBe('/b.jpg')
|
||||
|
||||
expect(transaction.acknowledgePrepared('/b.jpg', secondRevision)).toBe(true)
|
||||
await expect(secondPrepared).resolves.toBe(true)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('uses revision rather than URL identity for consecutive requests of the same wallpaper', async () => {
|
||||
const scope = effectScope()
|
||||
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
|
||||
const firstPrepared = transaction.requestPreparation('/same.jpg')
|
||||
const firstRevision = transaction.requestedRevision.value
|
||||
const secondPrepared = transaction.requestPreparation('/same.jpg')
|
||||
const secondRevision = transaction.requestedRevision.value
|
||||
|
||||
await expect(firstPrepared).resolves.toBe(false)
|
||||
expect(secondRevision).toBeGreaterThan(firstRevision)
|
||||
expect(transaction.acknowledgePrepared('/same.jpg', firstRevision)).toBe(false)
|
||||
expect(transaction.acknowledgePrepared('/same.jpg', secondRevision)).toBe(true)
|
||||
await expect(secondPrepared).resolves.toBe(true)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('cancels both waits on timeout and rejects a same-tick late acknowledgement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const scope = effectScope()
|
||||
const transaction = scope.run(() => useGlassWallpaperTransaction<number>(50))!
|
||||
const prepared = transaction.requestPreparation('/next.jpg')
|
||||
const revision = transaction.requestedRevision.value
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(prepared).resolves.toBe(false)
|
||||
expect(transaction.acknowledgePrepared('/next.jpg', revision)).toBe(false)
|
||||
expect(transaction.acknowledgeActivated('/next.jpg', revision, 50)).toBeNull()
|
||||
expect(transaction.requestedUrl.value).toBe('')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('cancels an activation wait without treating prepared as active', async () => {
|
||||
const scope = effectScope()
|
||||
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
|
||||
const prepared = transaction.requestPreparation('/next.jpg')
|
||||
const revision = transaction.requestedRevision.value
|
||||
|
||||
transaction.acknowledgePrepared('/next.jpg', revision)
|
||||
await expect(prepared).resolves.toBe(true)
|
||||
const activated = transaction.requestActivation(2)
|
||||
|
||||
expect(transaction.cancel(revision)).toBe(true)
|
||||
await expect(activated).resolves.toBe(false)
|
||||
expect(transaction.acknowledgeActivated('/next.jpg', revision, 100)).toBeNull()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps one deadline across prepared and activation instead of retaining GPU bundles indefinitely', async () => {
|
||||
vi.useFakeTimers()
|
||||
const scope = effectScope()
|
||||
const transaction = scope.run(() => useGlassWallpaperTransaction<number>(50))!
|
||||
const prepared = transaction.requestPreparation('/next.jpg')
|
||||
const revision = transaction.requestedRevision.value
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(transaction.acknowledgePrepared('/next.jpg', revision)).toBe(true)
|
||||
await expect(prepared).resolves.toBe(true)
|
||||
expect(transaction.requestedRevision.value).toBe(revision)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
|
||||
expect(transaction.requestedUrl.value).toBe('')
|
||||
expect(transaction.requestedRevision.value).toBe(0)
|
||||
expect(await transaction.requestActivation(3, revision)).toBe(false)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('rejects activation for a stale revision without replacing the current transaction payload', async () => {
|
||||
const scope = effectScope()
|
||||
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
|
||||
const prepared = transaction.requestPreparation('/next.jpg')
|
||||
const revision = transaction.requestedRevision.value
|
||||
|
||||
expect(await transaction.requestActivation(3, revision + 1)).toBe(false)
|
||||
expect(transaction.acknowledgePrepared('/next.jpg', revision)).toBe(true)
|
||||
await expect(prepared).resolves.toBe(true)
|
||||
const activated = transaction.requestActivation(4, revision)
|
||||
expect(transaction.acknowledgeActivated('/next.jpg', revision, 120)).toEqual({
|
||||
payload: 4,
|
||||
startedAt: 120,
|
||||
})
|
||||
await expect(activated).resolves.toBe(true)
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ beforeEach(() => {
|
||||
callbacks = new Map()
|
||||
frameId = 0
|
||||
document.documentElement.dataset.theme = 'glass'
|
||||
delete document.documentElement.dataset.launchLoading
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
@@ -30,13 +31,40 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
motion.cancel()
|
||||
document.getElementById('loading-bg')?.remove()
|
||||
delete document.documentElement.dataset.theme
|
||||
delete document.documentElement.dataset.launchLoading
|
||||
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('does not add a second reveal gate behind the initial launch screen', () => {
|
||||
document.documentElement.dataset.launchLoading = 'true'
|
||||
const launchScreen = document.createElement('div')
|
||||
launchScreen.id = 'loading-bg'
|
||||
document.body.append(launchScreen)
|
||||
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)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not suppress normal route motion when only a stale launch attribute remains', () => {
|
||||
document.documentElement.dataset.launchLoading = 'true'
|
||||
|
||||
expect(motion.start('/dashboard')).toBe(true)
|
||||
expect(motion.active.value).toBe(true)
|
||||
expect(motion.opacity.value).toBe(PAGE_PRESENTATION_MOTION_START_OPACITY)
|
||||
expect(callbacks.size).toBe(1)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBe('active')
|
||||
})
|
||||
|
||||
it('holds a glass route until its shared layout geometry remains stable', () => {
|
||||
const routeRoot = document.createElement('div')
|
||||
let routeHeight = 2096
|
||||
|
||||
@@ -6,11 +6,33 @@ import {
|
||||
previewGlassSettings,
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||
useThemeCustomizer,
|
||||
useEffectiveGlassSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
describe('useThemeCustomizer glass settings', () => {
|
||||
function mountThemeCustomizer() {
|
||||
let customizer: ReturnType<typeof useThemeCustomizer> | undefined
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
customizer = useThemeCustomizer()
|
||||
|
||||
return () => h('div')
|
||||
},
|
||||
}),
|
||||
{ global: { plugins: [vuetify] } },
|
||||
)
|
||||
|
||||
if (!customizer) throw new Error('theme customizer setup failed')
|
||||
|
||||
return { customizer, wrapper }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cancelGlassPreview()
|
||||
localStorage.clear()
|
||||
@@ -22,14 +44,15 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassDeformationStrength).toBe(50)
|
||||
expect(settings.glassFlowStrength).toBe(50)
|
||||
expect(settings.glassDeformationStrength).toBe(40)
|
||||
expect(settings.glassFlowStrength).toBe(40)
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassPresetOverrides).toEqual({})
|
||||
expect(settings.glassQuality).toBe('balanced')
|
||||
expect(settings.glassReflectionStrength).toBe(35)
|
||||
expect(settings.glassTransmissionStrength).toBe(70)
|
||||
expect(settings.glassTranslationStrength).toBe(50)
|
||||
expect(settings.glassTransparencyStrength).toBe(70)
|
||||
expect(settings.glassTransmissionStrength).toBe(54)
|
||||
expect(settings.glassTranslationStrength).toBe(40)
|
||||
expect(settings.glassTransparencyStrength).toBe(46)
|
||||
})
|
||||
|
||||
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
|
||||
@@ -54,6 +77,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassPresetOverrides).toHaveProperty('clear:balanced:natural')
|
||||
expect(settings.glassQuality).toBe('balanced')
|
||||
})
|
||||
|
||||
@@ -94,6 +118,19 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance: 'clear' }))
|
||||
|
||||
expect(readThemeCustomizerSettings().glassTransmissionStrength).toBe(50)
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:natural': {
|
||||
deformation: 40,
|
||||
flow: 40,
|
||||
reflection: 35,
|
||||
transmission: 50,
|
||||
translation: 40,
|
||||
transparency: 46,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs glass settings to the document roots', () => {
|
||||
@@ -111,10 +148,14 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(document.body.dataset.glassQuality).toBe('high')
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.35')
|
||||
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.35')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBe(1)
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBeCloseTo(54 / 70)
|
||||
expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(
|
||||
0.46639,
|
||||
)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.46639)
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72972)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.66215)
|
||||
})
|
||||
|
||||
it('previews glass settings without persisting them', () => {
|
||||
@@ -134,7 +175,17 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'glide',
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'tinted:css:natural': {
|
||||
deformation: 74,
|
||||
flow: 63,
|
||||
reflection: 81,
|
||||
transmission: 76,
|
||||
translation: 69,
|
||||
transparency: 80,
|
||||
},
|
||||
},
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 81,
|
||||
glassTransmissionStrength: 76,
|
||||
@@ -148,7 +199,17 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'glide',
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'tinted:css:natural': {
|
||||
deformation: 74,
|
||||
flow: 63,
|
||||
reflection: 81,
|
||||
transmission: 76,
|
||||
translation: 69,
|
||||
transparency: 80,
|
||||
},
|
||||
},
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 81,
|
||||
glassTransmissionStrength: 76,
|
||||
@@ -175,6 +236,16 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassFlowStrength: 44,
|
||||
glassPresetOverrides: {
|
||||
'tinted:balanced:natural': {
|
||||
deformation: 42,
|
||||
flow: 44,
|
||||
reflection: 66,
|
||||
transmission: 64,
|
||||
translation: 46,
|
||||
transparency: 72,
|
||||
},
|
||||
},
|
||||
glassReflectionStrength: 66,
|
||||
glassTransmissionStrength: 64,
|
||||
glassTranslationStrength: 46,
|
||||
@@ -184,6 +255,16 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 90,
|
||||
glassFlowStrength: 88,
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:natural': {
|
||||
deformation: 90,
|
||||
flow: 88,
|
||||
reflection: 12,
|
||||
transmission: 92,
|
||||
translation: 86,
|
||||
transparency: 94,
|
||||
},
|
||||
},
|
||||
glassReflectionStrength: 12,
|
||||
glassTransmissionStrength: 92,
|
||||
glassTranslationStrength: 86,
|
||||
@@ -206,10 +287,146 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassFlowStrength: 44,
|
||||
glassPresetOverrides: {
|
||||
'tinted:balanced:natural': {
|
||||
deformation: 42,
|
||||
flow: 44,
|
||||
reflection: 66,
|
||||
transmission: 64,
|
||||
translation: 46,
|
||||
transparency: 72,
|
||||
},
|
||||
},
|
||||
glassReflectionStrength: 66,
|
||||
glassTransmissionStrength: 64,
|
||||
glassTranslationStrength: 46,
|
||||
glassTransparencyStrength: 72,
|
||||
})
|
||||
})
|
||||
|
||||
it('applies the same preset for a new material and quality while preset-managed', async () => {
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: 'balanced',
|
||||
})
|
||||
const { customizer, wrapper } = mountThemeCustomizer()
|
||||
const { setGlassAppearance, setGlassPreset, setGlassQuality } = customizer
|
||||
|
||||
await setGlassPreset('glide')
|
||||
await setGlassAppearance('frosted')
|
||||
await setGlassQuality('high')
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassAppearance: 'frosted',
|
||||
glassDeformationStrength: 32,
|
||||
glassFlowStrength: 37,
|
||||
glassPreset: 'glide',
|
||||
glassQuality: 'high',
|
||||
glassReflectionStrength: 25,
|
||||
glassTransmissionStrength: 54,
|
||||
glassTranslationStrength: 56,
|
||||
glassTransparencyStrength: 38,
|
||||
})
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('restores each combination override after material and quality changes', async () => {
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: 'balanced',
|
||||
})
|
||||
const { customizer, wrapper } = mountThemeCustomizer()
|
||||
const { setGlassAppearance, setGlassDeformationStrength, setGlassQuality, setGlassTransparencyStrength } =
|
||||
customizer
|
||||
|
||||
await setGlassDeformationStrength(73)
|
||||
await setGlassTransparencyStrength(27)
|
||||
await setGlassAppearance('tinted')
|
||||
await setGlassQuality('high')
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:natural': {
|
||||
deformation: 73,
|
||||
flow: 40,
|
||||
reflection: 35,
|
||||
transmission: 54,
|
||||
translation: 40,
|
||||
transparency: 27,
|
||||
},
|
||||
},
|
||||
glassQuality: 'high',
|
||||
glassTransparencyStrength: 30,
|
||||
})
|
||||
await setGlassQuality('balanced')
|
||||
await setGlassAppearance('clear')
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassDeformationStrength: 73,
|
||||
glassTransparencyStrength: 27,
|
||||
})
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('maps preset-managed standard quality to natural without overwriting custom values', async () => {
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: 'balanced',
|
||||
})
|
||||
const { customizer, wrapper } = mountThemeCustomizer()
|
||||
const { setGlassPreset, setGlassQuality, setGlassTransparencyStrength } = customizer
|
||||
|
||||
await setGlassPreset('liquid')
|
||||
await setGlassQuality('css')
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassPreset: 'natural',
|
||||
glassQuality: 'css',
|
||||
glassTransparencyStrength: 48,
|
||||
})
|
||||
|
||||
await setGlassTransparencyStrength(19)
|
||||
await setGlassQuality('balanced')
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassPreset: 'natural',
|
||||
glassQuality: 'balanced',
|
||||
glassTransparencyStrength: 46,
|
||||
})
|
||||
await setGlassQuality('css')
|
||||
expect(readThemeCustomizerSettings().glassTransparencyStrength).toBe(19)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps overrides independent for two presets of the same material and quality', async () => {
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'tinted',
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: 'balanced',
|
||||
})
|
||||
const { customizer, wrapper } = mountThemeCustomizer()
|
||||
const { setGlassPreset, setGlassTransparencyStrength } = customizer
|
||||
|
||||
await setGlassPreset('glide')
|
||||
await setGlassTransparencyStrength(61)
|
||||
await setGlassPreset('liquid')
|
||||
await setGlassTransparencyStrength(37)
|
||||
await setGlassPreset('glide')
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassPreset: 'glide',
|
||||
glassPresetOverrides: {
|
||||
'tinted:balanced:glide': expect.objectContaining({ transparency: 61 }),
|
||||
'tinted:balanced:liquid': expect.objectContaining({ transparency: 37 }),
|
||||
},
|
||||
glassTransparencyStrength: 61,
|
||||
})
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
132
src/composables/useGlassWallpaperTransaction.ts
Normal file
132
src/composables/useGlassWallpaperTransaction.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
|
||||
interface GlassWallpaperActivation<TPayload> {
|
||||
/** 由请求方保存、在原子激活回执时取回的业务载荷。 */
|
||||
payload: TPayload
|
||||
/** fixed 与 scroll context 共用的激活时间戳。 */
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理壁纸从请求、双 context 预备到原子激活的单调事务。
|
||||
* prepared 只解除资源等待;请求必须保留到 active 回执或显式取消。
|
||||
*/
|
||||
export function useGlassWallpaperTransaction<TPayload>(timeoutMs = 10_000) {
|
||||
const requestedUrl = ref('')
|
||||
const requestedRevision = ref(0)
|
||||
const activationRevision = ref(0)
|
||||
let revisionSequence = 0
|
||||
let revisionTimer: number | null = null
|
||||
let preparationResolve: ((ready: boolean) => void) | null = null
|
||||
let activationResolve: ((activated: boolean) => void) | null = null
|
||||
let activationPayload: TPayload | null = null
|
||||
|
||||
function clearPreparationWait(ready: boolean) {
|
||||
const resolve = preparationResolve
|
||||
preparationResolve = null
|
||||
resolve?.(ready)
|
||||
}
|
||||
|
||||
function clearActivationWait(activated: boolean) {
|
||||
const resolve = activationResolve
|
||||
activationResolve = null
|
||||
activationPayload = null
|
||||
activationRevision.value = 0
|
||||
resolve?.(activated)
|
||||
}
|
||||
|
||||
function clearRevisionTimer() {
|
||||
if (revisionTimer === null) return
|
||||
|
||||
window.clearTimeout(revisionTimer)
|
||||
revisionTimer = null
|
||||
}
|
||||
|
||||
/** 取消当前 revision;迟到的 prepared/active 回执不能影响后续事务。 */
|
||||
function cancel(revision = requestedRevision.value) {
|
||||
if (revision !== requestedRevision.value) return false
|
||||
|
||||
clearRevisionTimer()
|
||||
clearPreparationWait(false)
|
||||
clearActivationWait(false)
|
||||
requestedUrl.value = ''
|
||||
requestedRevision.value = 0
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** 开始新的壁纸准备事务,并使上一事务的所有等待者立即失效。 */
|
||||
function requestPreparation(url: string) {
|
||||
cancel()
|
||||
const revision = ++revisionSequence
|
||||
requestedUrl.value = url
|
||||
requestedRevision.value = revision
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
preparationResolve = resolve
|
||||
revisionTimer = window.setTimeout(() => cancel(revision), timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
/** 只有当前 URL 与 revision 的 prepared 回执可以解除准备等待。 */
|
||||
function acknowledgePrepared(url: string, revision: number) {
|
||||
if (url !== requestedUrl.value || revision !== requestedRevision.value) return false
|
||||
|
||||
clearPreparationWait(true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** 请求 Layer 在同一绘制帧内激活两个 context 已准备的资源。 */
|
||||
function requestActivation(payload: TPayload, revision = requestedRevision.value) {
|
||||
if (!requestedUrl.value || revision <= 0 || revision !== requestedRevision.value) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
clearActivationWait(false)
|
||||
activationPayload = payload
|
||||
activationRevision.value = revision
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
activationResolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
/** 接受双 context 的原子 active 回执,并将请求载荷交还给父层提交 DOM。 */
|
||||
function acknowledgeActivated(
|
||||
url: string,
|
||||
revision: number,
|
||||
startedAt: number,
|
||||
): GlassWallpaperActivation<TPayload> | null {
|
||||
if (
|
||||
url !== requestedUrl.value ||
|
||||
revision !== requestedRevision.value ||
|
||||
revision !== activationRevision.value ||
|
||||
activationPayload === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activation = { payload: activationPayload, startedAt }
|
||||
clearRevisionTimer()
|
||||
clearPreparationWait(true)
|
||||
clearActivationWait(true)
|
||||
requestedUrl.value = ''
|
||||
requestedRevision.value = 0
|
||||
|
||||
return activation
|
||||
}
|
||||
|
||||
onScopeDispose(cancel)
|
||||
|
||||
return {
|
||||
acknowledgeActivated,
|
||||
acknowledgePrepared,
|
||||
activationRevision,
|
||||
cancel,
|
||||
requestedRevision,
|
||||
requestedUrl,
|
||||
requestActivation,
|
||||
requestPreparation,
|
||||
}
|
||||
}
|
||||
@@ -121,9 +121,7 @@ function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElem
|
||||
return
|
||||
}
|
||||
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp =>
|
||||
sampleLayoutHold(nextTimestamp, motionEpoch, root),
|
||||
)
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp => sampleLayoutHold(nextTimestamp, motionEpoch, root))
|
||||
}
|
||||
|
||||
function settleMotion() {
|
||||
@@ -178,6 +176,13 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
|
||||
const motionEpoch = epoch.value
|
||||
routeKey.value = nextRouteKey
|
||||
|
||||
// 启动屏已完整遮罩页面;在其背后再等待布局稳定会把一次启动拆成两次可见揭示。
|
||||
if (document.documentElement.dataset.launchLoading === 'true' && document.getElementById('loading-bg')) {
|
||||
settleMotion()
|
||||
revision.value += 1
|
||||
return true
|
||||
}
|
||||
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
settleMotion()
|
||||
revision.value += 1
|
||||
|
||||
@@ -6,12 +6,17 @@ import vuetify from '@/plugins/vuetify'
|
||||
import {
|
||||
GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
GLASS_OPTICAL_STRENGTH_MAX,
|
||||
getGlassCssFrostBlur,
|
||||
getGlassMaterialResponse,
|
||||
getGlassOpticalCssTransmissionBrightness,
|
||||
getGlassOpticalPresetKey,
|
||||
getGlassOpticalPresetParameters,
|
||||
getGlassOpticalPresetParametersWithOverrides,
|
||||
getGlassOpticalTransmissionStrength,
|
||||
getGlassOpticalTransparency,
|
||||
normalizeGlassOpticalStrength,
|
||||
type GlassOpticalParameters,
|
||||
type GlassOpticalPreset,
|
||||
type GlassOpticalPresetOverrides,
|
||||
} from '@/utils/glassOptics'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { syncThemeFavicon } from '@/utils/themePalette'
|
||||
@@ -80,6 +85,8 @@ export interface ThemeCustomizerSettings {
|
||||
glassFlowStrength: number
|
||||
/** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */
|
||||
glassPreset: GlassOpticalPreset
|
||||
/** 按材质、质量与方案保存的六参数覆盖;缺失组合使用预设矩阵。 */
|
||||
glassPresetOverrides: GlassOpticalPresetOverrides
|
||||
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
|
||||
glassQuality: ThemeCustomizerGlassQuality
|
||||
/** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */
|
||||
@@ -155,6 +162,7 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
glassDeformationStrength: glassParameters.deformation,
|
||||
glassFlowStrength: glassParameters.flow,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTransmissionStrength: glassParameters.transmission,
|
||||
@@ -183,6 +191,49 @@ function normalizeMigratedGlassStrength(value: unknown, legacyValue: unknown, fa
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 规范化一组六参数,非法结构不进入持久化覆盖。 */
|
||||
function normalizeGlassParameters(value: unknown): GlassOpticalParameters | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
|
||||
const parameters = value as Partial<GlassOpticalParameters>
|
||||
const fields: Array<keyof GlassOpticalParameters> = [
|
||||
'deformation',
|
||||
'flow',
|
||||
'reflection',
|
||||
'transmission',
|
||||
'translation',
|
||||
'transparency',
|
||||
]
|
||||
if (fields.some(field => typeof parameters[field] !== 'number' || !Number.isFinite(parameters[field]))) return null
|
||||
|
||||
return {
|
||||
deformation: normalizeGlassOpticalStrength(parameters.deformation),
|
||||
flow: normalizeGlassOpticalStrength(parameters.flow),
|
||||
reflection: normalizeGlassOpticalStrength(parameters.reflection),
|
||||
transmission: normalizeGlassOpticalStrength(parameters.transmission),
|
||||
translation: normalizeGlassOpticalStrength(parameters.translation),
|
||||
transparency: normalizeGlassOpticalStrength(parameters.transparency),
|
||||
}
|
||||
}
|
||||
|
||||
/** 只保留 21 个有效组合的完整六参数覆盖。 */
|
||||
function normalizeGlassPresetOverrides(value: unknown): GlassOpticalPresetOverrides {
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
|
||||
const normalized: GlassOpticalPresetOverrides = {}
|
||||
for (const appearance of validGlassAppearances) {
|
||||
for (const quality of validGlassQualities) {
|
||||
for (const preset of quality === 'css' ? (['natural'] as const) : validGlassPresets) {
|
||||
const key = getGlassOpticalPresetKey(appearance, quality, preset)
|
||||
const parameters = normalizeGlassParameters((value as Record<string, unknown>)[key])
|
||||
if (parameters) normalized[key] = parameters
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
|
||||
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
|
||||
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow
|
||||
@@ -200,8 +251,11 @@ function normalizeThemeCustomizerSettings(
|
||||
const storedRadius = settings.radius as string | undefined
|
||||
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
|
||||
const storedPreset = validGlassPresets.includes(settings.glassPreset as GlassOpticalPreset)
|
||||
? (settings.glassPreset as GlassOpticalPreset)
|
||||
: fallback.glassPreset
|
||||
|
||||
return {
|
||||
const normalized: ThemeCustomizerSettings = {
|
||||
glassAppearance: validGlassAppearances.includes(settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||
? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||
: fallback.glassAppearance,
|
||||
@@ -215,9 +269,8 @@ function normalizeThemeCustomizerSettings(
|
||||
settings.glassMotionStrength,
|
||||
fallback.glassFlowStrength,
|
||||
),
|
||||
glassPreset: validGlassPresets.includes(settings.glassPreset as GlassOpticalPreset)
|
||||
? (settings.glassPreset as GlassOpticalPreset)
|
||||
: fallback.glassPreset,
|
||||
glassPreset: storedPreset,
|
||||
glassPresetOverrides: normalizeGlassPresetOverrides(settings.glassPresetOverrides),
|
||||
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||
? (settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||
: fallback.glassQuality,
|
||||
@@ -254,6 +307,19 @@ function normalizeThemeCustomizerSettings(
|
||||
? (settings.theme as ThemeCustomizerTheme)
|
||||
: fallback.theme,
|
||||
}
|
||||
if (preserveLegacyTransmission && settings.glassPresetOverrides === undefined) {
|
||||
const key = getGlassOpticalPresetKey(normalized.glassAppearance, normalized.glassQuality, normalized.glassPreset)
|
||||
normalized.glassPresetOverrides[key] = {
|
||||
deformation: normalized.glassDeformationStrength,
|
||||
flow: normalized.glassFlowStrength,
|
||||
reflection: normalized.glassReflectionStrength,
|
||||
transmission: normalized.glassTransmissionStrength,
|
||||
translation: normalized.glassTranslationStrength,
|
||||
transparency: normalized.glassTransparencyStrength,
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** 从本地存储读取主题定制器设置,异常数据会自动回落到默认值。 */
|
||||
@@ -281,6 +347,7 @@ type ThemeCustomizerGlassSettings = Pick<
|
||||
| 'glassDeformationStrength'
|
||||
| 'glassFlowStrength'
|
||||
| 'glassPreset'
|
||||
| 'glassPresetOverrides'
|
||||
| 'glassQuality'
|
||||
| 'glassReflectionStrength'
|
||||
| 'glassTransmissionStrength'
|
||||
@@ -294,6 +361,7 @@ const effectiveGlassSettings = computed(() => ({
|
||||
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
|
||||
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
|
||||
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
|
||||
glassPresetOverrides: glassPreviewState.value?.glassPresetOverrides ?? settingsState.value.glassPresetOverrides,
|
||||
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
|
||||
glassReflectionStrength:
|
||||
glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength,
|
||||
@@ -375,6 +443,18 @@ export function applyThemeCustomizerRootSettings(
|
||||
) {
|
||||
if (!isBrowser()) return
|
||||
|
||||
const materialResponse = getGlassMaterialResponse(settings.glassAppearance, settings.glassTransparencyStrength)
|
||||
const frostBlur = getGlassCssFrostBlur(settings.glassTransparencyStrength)
|
||||
const applyGlassResponse = (element: HTMLElement) => {
|
||||
element.style.setProperty('--glass-background-visibility', String(materialResponse.backgroundVisibility))
|
||||
element.style.setProperty('--glass-frost-blur-scale', String(materialResponse.frostBlurScale))
|
||||
element.style.setProperty('--glass-frost-detail-level', String(materialResponse.frostDetailLevel))
|
||||
element.style.setProperty('--glass-surface-density', String(materialResponse.surfaceDensity))
|
||||
element.style.setProperty('--glass-tint-density', String(materialResponse.tintDensity))
|
||||
element.style.setProperty('--glass-blur-surface', `${frostBlur.surface}px`)
|
||||
element.style.setProperty('--glass-blur-raised', `${frostBlur.raised}px`)
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
|
||||
document.documentElement.setAttribute('data-glass-quality', settings.glassQuality)
|
||||
document.documentElement.style.setProperty(
|
||||
@@ -389,10 +469,7 @@ export function applyThemeCustomizerRootSettings(
|
||||
'--glass-transmission-brightness',
|
||||
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--glass-transparency',
|
||||
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
|
||||
)
|
||||
applyGlassResponse(document.documentElement)
|
||||
document.documentElement.setAttribute('data-theme-layout', settings.layout)
|
||||
document.documentElement.setAttribute('data-theme-radius', settings.radius)
|
||||
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||
@@ -412,10 +489,7 @@ export function applyThemeCustomizerRootSettings(
|
||||
'--glass-transmission-brightness',
|
||||
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
|
||||
)
|
||||
document.body.style.setProperty(
|
||||
'--glass-transparency',
|
||||
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
|
||||
)
|
||||
applyGlassResponse(document.body)
|
||||
document.body.setAttribute('data-theme-layout', settings.layout)
|
||||
document.body.setAttribute('data-theme-radius', settings.radius)
|
||||
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||
@@ -496,6 +570,7 @@ export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings
|
||||
glassDeformationStrength: previewSettings.glassDeformationStrength,
|
||||
glassFlowStrength: previewSettings.glassFlowStrength,
|
||||
glassPreset: previewSettings.glassPreset,
|
||||
glassPresetOverrides: previewSettings.glassPresetOverrides,
|
||||
glassQuality: previewSettings.glassQuality,
|
||||
glassReflectionStrength: previewSettings.glassReflectionStrength,
|
||||
glassTransmissionStrength: previewSettings.glassTransmissionStrength,
|
||||
@@ -539,6 +614,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTransmissionStrength: glassParameters.transmission,
|
||||
@@ -558,6 +634,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
|
||||
settings.glassFlowStrength === defaults.glassFlowStrength &&
|
||||
settings.glassPreset === defaults.glassPreset &&
|
||||
JSON.stringify(settings.glassPresetOverrides) === JSON.stringify(defaults.glassPresetOverrides) &&
|
||||
settings.glassQuality === defaults.glassQuality &&
|
||||
settings.glassReflectionStrength === defaults.glassReflectionStrength &&
|
||||
settings.glassTransmissionStrength === defaults.glassTransmissionStrength &&
|
||||
@@ -607,49 +684,137 @@ export function useThemeCustomizer() {
|
||||
return updateSettings({ primaryColor: color })
|
||||
}
|
||||
|
||||
/** 读取当前组合的六个具体参数。 */
|
||||
function getCurrentGlassParameters(): GlassOpticalParameters {
|
||||
return {
|
||||
deformation: settings.value.glassDeformationStrength,
|
||||
flow: settings.value.glassFlowStrength,
|
||||
reflection: settings.value.glassReflectionStrength,
|
||||
transmission: settings.value.glassTransmissionStrength,
|
||||
translation: settings.value.glassTranslationStrength,
|
||||
transparency: settings.value.glassTransparencyStrength,
|
||||
}
|
||||
}
|
||||
|
||||
/** 用调整后的完整六参数覆盖当前材质、质量与方案组合。 */
|
||||
function updateGlassPresetOverride(patch: Partial<GlassOpticalParameters>) {
|
||||
const parameters = {
|
||||
...getCurrentGlassParameters(),
|
||||
...patch,
|
||||
}
|
||||
const key = getGlassOpticalPresetKey(
|
||||
settings.value.glassAppearance,
|
||||
settings.value.glassQuality,
|
||||
settings.value.glassPreset,
|
||||
)
|
||||
|
||||
return updateSettings({
|
||||
glassDeformationStrength: parameters.deformation,
|
||||
glassFlowStrength: parameters.flow,
|
||||
glassPresetOverrides: {
|
||||
...settings.value.glassPresetOverrides,
|
||||
[key]: parameters,
|
||||
},
|
||||
glassReflectionStrength: parameters.reflection,
|
||||
glassTransmissionStrength: parameters.transmission,
|
||||
glassTranslationStrength: parameters.translation,
|
||||
glassTransparencyStrength: parameters.transparency,
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新玻璃主题材质,不隐式改变渲染质量。 */
|
||||
function setGlassAppearance(glassAppearance: ThemeCustomizerGlassAppearance) {
|
||||
return updateSettings({ glassAppearance })
|
||||
const glassPreset = settings.value.glassQuality === 'css' ? 'natural' : settings.value.glassPreset
|
||||
const parameters = getGlassOpticalPresetParametersWithOverrides(
|
||||
glassAppearance,
|
||||
settings.value.glassQuality,
|
||||
glassPreset,
|
||||
settings.value.glassPresetOverrides,
|
||||
)
|
||||
|
||||
return updateSettings({
|
||||
glassAppearance,
|
||||
glassDeformationStrength: parameters.deformation,
|
||||
glassFlowStrength: parameters.flow,
|
||||
glassPreset,
|
||||
glassReflectionStrength: parameters.reflection,
|
||||
glassTransmissionStrength: parameters.transmission,
|
||||
glassTranslationStrength: parameters.translation,
|
||||
glassTransparencyStrength: parameters.transparency,
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新玻璃局部非均匀形变强度。 */
|
||||
function setGlassDeformationStrength(glassDeformationStrength: number) {
|
||||
return updateSettings({ glassDeformationStrength })
|
||||
return updateGlassPresetOverride({ deformation: normalizeGlassOpticalStrength(glassDeformationStrength) })
|
||||
}
|
||||
|
||||
/** 更新玻璃轨迹、尾波与惯性强度。 */
|
||||
function setGlassFlowStrength(glassFlowStrength: number) {
|
||||
return updateSettings({ glassFlowStrength })
|
||||
return updateGlassPresetOverride({ flow: normalizeGlassOpticalStrength(glassFlowStrength) })
|
||||
}
|
||||
|
||||
/** 更新当前玻璃方案,不隐式覆盖用户已保存的具体参数。 */
|
||||
/** 切换方案时优先恢复该组合已保存的覆盖。 */
|
||||
function setGlassPreset(glassPreset: GlassOpticalPreset) {
|
||||
return updateSettings({ glassPreset })
|
||||
const effectivePreset = settings.value.glassQuality === 'css' ? 'natural' : glassPreset
|
||||
const parameters = getGlassOpticalPresetParametersWithOverrides(
|
||||
settings.value.glassAppearance,
|
||||
settings.value.glassQuality,
|
||||
effectivePreset,
|
||||
settings.value.glassPresetOverrides,
|
||||
)
|
||||
|
||||
return updateSettings({
|
||||
glassDeformationStrength: parameters.deformation,
|
||||
glassFlowStrength: parameters.flow,
|
||||
glassPreset: effectivePreset,
|
||||
glassReflectionStrength: parameters.reflection,
|
||||
glassTransmissionStrength: parameters.transmission,
|
||||
glassTranslationStrength: parameters.translation,
|
||||
glassTransparencyStrength: parameters.transparency,
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新玻璃主题渲染质量档位。 */
|
||||
function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) {
|
||||
return updateSettings({ glassQuality })
|
||||
const glassPreset: GlassOpticalPreset = glassQuality === 'css' ? 'natural' : settings.value.glassPreset
|
||||
const parameters = getGlassOpticalPresetParametersWithOverrides(
|
||||
settings.value.glassAppearance,
|
||||
glassQuality,
|
||||
glassPreset,
|
||||
settings.value.glassPresetOverrides,
|
||||
)
|
||||
|
||||
return updateSettings({
|
||||
glassDeformationStrength: parameters.deformation,
|
||||
glassFlowStrength: parameters.flow,
|
||||
glassPreset,
|
||||
glassQuality,
|
||||
glassReflectionStrength: parameters.reflection,
|
||||
glassTransmissionStrength: parameters.transmission,
|
||||
glassTranslationStrength: parameters.translation,
|
||||
glassTransparencyStrength: parameters.transparency,
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新玻璃表面反射亮度。 */
|
||||
function setGlassReflectionStrength(glassReflectionStrength: number) {
|
||||
return updateSettings({ glassReflectionStrength })
|
||||
return updateGlassPresetOverride({ reflection: normalizeGlassOpticalStrength(glassReflectionStrength) })
|
||||
}
|
||||
|
||||
/** 更新玻璃内部壁纸采样的透射亮度。 */
|
||||
function setGlassTransmissionStrength(glassTransmissionStrength: number) {
|
||||
return updateSettings({ glassTransmissionStrength })
|
||||
return updateGlassPresetOverride({ transmission: normalizeGlassOpticalStrength(glassTransmissionStrength) })
|
||||
}
|
||||
|
||||
/** 更新玻璃统一采样平移强度。 */
|
||||
function setGlassTranslationStrength(glassTranslationStrength: number) {
|
||||
return updateSettings({ glassTranslationStrength })
|
||||
return updateGlassPresetOverride({ translation: normalizeGlassOpticalStrength(glassTranslationStrength) })
|
||||
}
|
||||
|
||||
/** 更新玻璃材质的真实壁纸可见度。 */
|
||||
function setGlassTransparencyStrength(glassTransparencyStrength: number) {
|
||||
return updateSettings({ glassTransparencyStrength })
|
||||
return updateGlassPresetOverride({ transparency: normalizeGlassOpticalStrength(glassTransparencyStrength) })
|
||||
}
|
||||
|
||||
/** 更新全局圆角档位。 */
|
||||
@@ -691,6 +856,7 @@ export function useThemeCustomizer() {
|
||||
glassDeformationStrength: glassParameters.deformation,
|
||||
glassFlowStrength: glassParameters.flow,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTransmissionStrength: glassParameters.transmission,
|
||||
|
||||
@@ -631,7 +631,7 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 UI模式设置 - 使用嵌套菜单 -->
|
||||
<VMenu location="end" offset-x min-width="200" v-model="showUIModeMenu" :close-on-content-click="true">
|
||||
<VMenu location="end" offset-x width="15rem" v-model="showUIModeMenu" :close-on-content-click="true">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
@@ -666,7 +666,7 @@ onUnmounted(() => {
|
||||
</VMenu>
|
||||
|
||||
<!-- 👉 主题设置 - 使用嵌套菜单 -->
|
||||
<VMenu location="end" offset-x min-width="200" v-model="showThemeMenu" :close-on-content-click="true">
|
||||
<VMenu location="end" offset-x width="15rem" v-model="showThemeMenu" :close-on-content-click="true">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
@@ -730,7 +730,7 @@ onUnmounted(() => {
|
||||
</VMenu>
|
||||
|
||||
<!-- 👉 语言设置 - 使用嵌套菜单 -->
|
||||
<VMenu location="end" offset-x min-width="200" v-model="showLanguageMenu" :close-on-content-click="true">
|
||||
<VMenu location="end" offset-x width="15rem" v-model="showLanguageMenu" :close-on-content-click="true">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
|
||||
@@ -175,7 +175,7 @@ export default {
|
||||
glassOpticalStrengthHint:
|
||||
'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.',
|
||||
glassOpticalStrengthUnavailableHint:
|
||||
'Standard quality keeps all three material controls. Switch to Balanced or High to adjust sample translation, deformation, and flow.',
|
||||
'Standard quality keeps all three material controls; switch to Balanced or High to adjust sample translation, deformation, and flow.',
|
||||
purple: 'Purple',
|
||||
custom: 'Custom Style',
|
||||
transparency: 'Transparency',
|
||||
@@ -3810,8 +3810,7 @@ export default {
|
||||
password: 'Password',
|
||||
passwordHint: 'Login password',
|
||||
accessCode: 'Access Code',
|
||||
accessCodeHint:
|
||||
'Access code enabled in fnOS Settings -> Security -> Access Settings. Leave empty if not enabled',
|
||||
accessCodeHint: 'Access code enabled in fnOS Settings -> Security -> Access Settings. Leave empty if not enabled',
|
||||
syncLibraries: 'Sync Libraries',
|
||||
syncLibrariesHint: 'Only selected libraries will be synchronized',
|
||||
scanMode: 'Scan Mode',
|
||||
|
||||
@@ -170,7 +170,7 @@ export default {
|
||||
glassTransmissionStrength: '透射亮度',
|
||||
glassTransparencyStrength: '通透度',
|
||||
glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。',
|
||||
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数。切换到均衡或高质量后,可调整采样平移、形变和流动。',
|
||||
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数,切换到均衡或高质量后,可调整采样平移、形变和流动。',
|
||||
purple: '幻紫',
|
||||
custom: '附加样式',
|
||||
transparency: '透明度',
|
||||
|
||||
@@ -170,7 +170,7 @@ export default {
|
||||
glassTransmissionStrength: '透射亮度',
|
||||
glassTransparencyStrength: '通透度',
|
||||
glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。',
|
||||
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數。切換到均衡或高品質後,可調整採樣平移、形變和流動。',
|
||||
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數,切換到均衡或高品質後,可調整採樣平移、形變和流動。',
|
||||
purple: '幻紫',
|
||||
custom: '附加樣式',
|
||||
transparency: '透明度',
|
||||
|
||||
67
src/styles/__tests__/glassOverlayMaterial.spec.ts
Normal file
67
src/styles/__tests__/glassOverlayMaterial.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { cwd } from 'node:process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('glass overlay material styles', () => {
|
||||
it('keeps overlays translucent enough for CSS backdrop compositing in every material', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('calc(0.1 + var(--glass-surface-density, 0.62) * 0.22)')
|
||||
expect(styles).toContain('--glass-overlay-blur: 3px')
|
||||
expect(styles).toContain('--glass-overlay-saturate: 115%')
|
||||
expect(styles).toContain('--glass-overlay-blur: 12px')
|
||||
expect(styles).toContain('--glass-overlay-saturate: 120%')
|
||||
expect(styles).toContain('--glass-overlay-blur: min(var(--glass-blur-raised), 36px)')
|
||||
expect(styles).toContain('--glass-overlay-saturate: 135%')
|
||||
expect(styles).toContain('--glass-overlay-scrim: rgba(3, 7, 18, 30%)')
|
||||
expect(styles).toContain('--glass-overlay-scrim: rgba(3, 7, 18, 32%)')
|
||||
expect(styles).toContain('--glass-overlay-scrim: rgba(3, 7, 18, 36%)')
|
||||
expect(styles).toContain('calc(0.24 + var(--glass-surface-density, 0.86) * 0.12)')
|
||||
expect(styles).not.toContain('calc(0.64 + var(--glass-surface-density, 0.86) * 0.16)')
|
||||
expect(styles).not.toContain('background: rgba(3, 7, 18, 62%)')
|
||||
})
|
||||
|
||||
it('composites glass dialogs at their final geometry instead of resampling a scaled backdrop', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toMatch(
|
||||
/\.v-overlay__content\.mp-dialog-transition-enter-active[\s\S]*?transition:\s*opacity 120ms var\(--mp-motion-ease-standard\);/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.v-overlay__content\.mp-dialog-transition-enter-from[\s\S]*?filter:\s*none;[\s\S]*?transform:\s*none;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the fixed navigation backdrop isolated from route content and its scrollbar', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('--glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px))')
|
||||
expect(styles).toMatch(
|
||||
/\.layout-vertical-nav\s*\{[\s\S]*?isolation:\s*isolate;[\s\S]*?backdrop-filter:\s*none;[\s\S]*?&::before\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-fixed-shell-backdrop-filter\);/,
|
||||
)
|
||||
expect(styles).toMatch(/\.layout-vertical-nav \.ps__rail-y\s*\{[\s\S]*?inset-inline-end:\s*0\.5rem !important;/)
|
||||
})
|
||||
|
||||
it('uses the shared hover-card contract instead of a Dashboard-specific shadow rule', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('.app-hover-lift-card:is(:hover, .app-hover-lift-card--hovering)')
|
||||
expect(styles).not.toContain(
|
||||
'.dashboard-grid-item-content .app-hover-lift-card:is(:hover, .app-hover-lift-card--hovering)',
|
||||
)
|
||||
})
|
||||
|
||||
it('hands the CSS fallback to an already rendered canvas without a second opacity transition', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const layerRuleStart = styles.indexOf('.glass-optical-layer {')
|
||||
const layerRuleEnd = styles.indexOf('.glass-optical-layer--fixed', layerRuleStart)
|
||||
const layerRule = styles.slice(layerRuleStart, layerRuleEnd)
|
||||
|
||||
expect(layerRuleStart).toBeGreaterThanOrEqual(0)
|
||||
expect(layerRuleEnd).toBeGreaterThan(layerRuleStart)
|
||||
expect(layerRule).toContain('opacity: 0')
|
||||
expect(layerRule).not.toMatch(/transition\s*:/)
|
||||
expect(styles).toMatch(/\[data-glass-renderer-state='ready'\]\s*\.glass-optical-layer\s*\{\s*opacity:\s*1;/)
|
||||
})
|
||||
})
|
||||
18
src/styles/__tests__/pluginCardAccent.spec.ts
Normal file
18
src/styles/__tests__/pluginCardAccent.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { cwd } from 'node:process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('plugin card accent styles', () => {
|
||||
it('inherits the icon-derived accent instead of shadowing it on the banner', () => {
|
||||
const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8')
|
||||
const ruleStart = commonStyles.indexOf('.plugin-card__banner')
|
||||
const ruleEnd = commonStyles.indexOf('.grid-downloading-card', ruleStart)
|
||||
const bannerRule = commonStyles.slice(ruleStart, ruleEnd)
|
||||
|
||||
expect(ruleStart).toBeGreaterThanOrEqual(0)
|
||||
expect(ruleEnd).toBeGreaterThan(ruleStart)
|
||||
expect(bannerRule).not.toMatch(/--plugin-card-accent-rgb\s*:/)
|
||||
expect(bannerRule).toContain('var(--plugin-card-accent-rgb, 40, 169, 225)')
|
||||
})
|
||||
})
|
||||
@@ -1346,11 +1346,10 @@ html[data-theme='transparent'].transparent-glass-realtime .v-theme--transparent
|
||||
background-image: var(--plugin-card-banner-scrim), var(--plugin-card-banner-tint);
|
||||
transition: background-image 0.2s ease;
|
||||
|
||||
--plugin-card-accent-rgb: 40, 169, 225;
|
||||
--plugin-card-banner-scrim: linear-gradient(rgba(0, 0, 0, 60%) 0%, rgba(0, 0, 0, 50%) 100%);
|
||||
--plugin-card-banner-tint: linear-gradient(
|
||||
rgb(var(--plugin-card-accent-rgb)) 0%,
|
||||
rgb(var(--plugin-card-accent-rgb)) 100%
|
||||
rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 0%,
|
||||
rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
// 材质只覆盖统一表面 token;质量档只替换光学层,不改变业务组件契约。
|
||||
html[data-theme='glass'] {
|
||||
--glass-surface: rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24));
|
||||
--glass-surface-soft: rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24));
|
||||
--glass-surface-raised: rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24));
|
||||
--glass-surface: rgba(11, 19, 34, calc(0.02 + var(--glass-surface-density, 0.62) * 0.3));
|
||||
--glass-surface-soft: rgba(11, 19, 34, calc(0.04 + var(--glass-surface-density, 0.62) * 0.32));
|
||||
--glass-surface-raised: rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.62) * 0.36));
|
||||
--glass-control: rgba(11, 19, 34, 52%);
|
||||
--glass-control-prominent: rgba(255, 255, 255, 7%);
|
||||
--glass-control-prominent-focus: color-mix(in srgb, rgba(255, 255, 255, 10%) 84%, rgba(var(--v-theme-primary), 24%));
|
||||
@@ -44,12 +44,14 @@ html[data-theme='glass'] {
|
||||
inset 0 -1px 0 rgba(2, 6, 16, 16%);
|
||||
--glass-surface-backdrop-filter: brightness(var(--glass-transmission-brightness));
|
||||
--glass-raised-backdrop-filter: brightness(var(--glass-transmission-brightness));
|
||||
--glass-fixed-shell-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
--glass-control-backdrop-filter: none;
|
||||
--glass-control-prominent-backdrop-filter: none;
|
||||
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
--glass-overlay-surface: var(--glass-surface-raised);
|
||||
--glass-overlay-surface: rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.22));
|
||||
--glass-overlay-blur: 3px;
|
||||
--glass-overlay-saturate: 135%;
|
||||
--glass-overlay-saturate: 115%;
|
||||
--glass-overlay-scrim: rgba(3, 7, 18, 30%);
|
||||
--glass-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate));
|
||||
--glass-control-icon-color: rgba(242, 245, 250, 70%);
|
||||
--glass-control-placeholder-color: rgba(242, 245, 250, 54%);
|
||||
@@ -112,18 +114,18 @@ html[data-theme='glass'] {
|
||||
&[data-glass-appearance='tinted'] {
|
||||
--glass-surface: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24)) 88%,
|
||||
rgba(var(--v-theme-primary), 28%)
|
||||
rgba(11, 19, 34, calc(0.02 + var(--glass-surface-density, 0.72) * 0.3)) 88%,
|
||||
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.28))
|
||||
);
|
||||
--glass-surface-soft: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24)) 89%,
|
||||
rgba(var(--v-theme-primary), 26%)
|
||||
rgba(11, 19, 34, calc(0.04 + var(--glass-surface-density, 0.72) * 0.32)) 89%,
|
||||
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.26))
|
||||
);
|
||||
--glass-surface-raised: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24)) 84%,
|
||||
rgba(var(--v-theme-primary), 32%)
|
||||
rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.72) * 0.36)) 84%,
|
||||
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.32))
|
||||
);
|
||||
--glass-control: color-mix(in srgb, rgba(11, 19, 34, 52%) 88%, rgba(var(--v-theme-primary), 28%));
|
||||
--glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%));
|
||||
@@ -164,13 +166,21 @@ html[data-theme='glass'] {
|
||||
rgba(var(--v-theme-primary), 6%) 72%,
|
||||
transparent
|
||||
);
|
||||
--glass-overlay-surface: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.2)) 88%,
|
||||
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.22))
|
||||
);
|
||||
--glass-overlay-blur: 12px;
|
||||
--glass-overlay-saturate: 120%;
|
||||
--glass-overlay-scrim: rgba(3, 7, 18, 32%);
|
||||
}
|
||||
|
||||
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
|
||||
&[data-glass-appearance='frosted'] {
|
||||
--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-surface: rgba(255, 255, 255, calc(0.035 + var(--glass-surface-density, 0.86) * 0.075));
|
||||
--glass-surface-soft: rgba(255, 255, 255, calc(0.03 + var(--glass-surface-density, 0.86) * 0.07));
|
||||
--glass-surface-raised: rgba(255, 255, 255, calc(0.045 + var(--glass-surface-density, 0.86) * 0.09));
|
||||
--glass-control: rgba(255, 255, 255, 9%);
|
||||
--glass-control-prominent: rgba(255, 255, 255, 10%);
|
||||
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
|
||||
@@ -194,13 +204,14 @@ html[data-theme='glass'] {
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
// 固定全高导航限制采样半径,避免页面重绘扩大其 backdrop 栅格化区域。
|
||||
--glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate))
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
|
||||
--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-overlay-surface: rgba(var(--v-theme-background), calc(0.24 + var(--glass-surface-density, 0.86) * 0.12));
|
||||
--glass-overlay-blur: min(var(--glass-blur-raised), 36px);
|
||||
--glass-overlay-saturate: 135%;
|
||||
--glass-overlay-scrim: rgba(3, 7, 18, 36%);
|
||||
--glass-blur-surface: 40px;
|
||||
--glass-blur: 40px;
|
||||
--glass-blur-raised: 60px;
|
||||
@@ -215,10 +226,6 @@ 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) {
|
||||
@@ -438,8 +445,8 @@ html[data-theme='glass'] {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-inline-end: 1px solid var(--glass-border-raised);
|
||||
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
|
||||
backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
|
||||
background-color: var(--glass-surface-raised);
|
||||
background-image: var(--glass-sheen);
|
||||
box-shadow: var(--glass-shadow-raised);
|
||||
@@ -575,7 +582,7 @@ html[data-theme='glass'] {
|
||||
.v-overlay__scrim {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
background: rgba(3, 7, 18, 48%);
|
||||
background: var(--glass-overlay-scrim);
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen {
|
||||
@@ -595,6 +602,25 @@ html[data-theme='glass'] {
|
||||
}
|
||||
}
|
||||
|
||||
// 弹层材质按最终几何一次合成,避免 transform/filter 动画反复重采样背景。
|
||||
.v-overlay__content.mp-dialog-transition-enter-active,
|
||||
.v-overlay__content.mp-dialog-transition-leave-active,
|
||||
.v-overlay__content.dialog-transition-enter-active,
|
||||
.v-overlay__content.dialog-transition-leave-active {
|
||||
filter: none;
|
||||
transform: none;
|
||||
transition: opacity 120ms var(--mp-motion-ease-standard);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.v-overlay__content.mp-dialog-transition-enter-from,
|
||||
.v-overlay__content.mp-dialog-transition-leave-to,
|
||||
.v-overlay__content.dialog-transition-enter-from,
|
||||
.v-overlay__content.dialog-transition-leave-to {
|
||||
filter: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
// 透明材质在内容上滚后复用移动底栏材质,顶部时则保持原有透光感。
|
||||
&[data-glass-appearance='clear'] {
|
||||
.layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar,
|
||||
@@ -814,14 +840,14 @@ html[data-theme='glass'] {
|
||||
border-block-end: 1px solid var(--glass-border);
|
||||
|
||||
--plugin-card-banner-scrim: linear-gradient(
|
||||
rgba(11, 19, 34, calc(0.34 - var(--glass-transparency, 0.5) * 0.14)) 0%,
|
||||
rgba(11, 19, 34, calc(0.46 - var(--glass-transparency, 0.5) * 0.16)) 100%
|
||||
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.115)) 0%,
|
||||
rgba(11, 19, 34, calc(0.28 + var(--glass-surface-density, 0.62) * 0.16)) 100%
|
||||
);
|
||||
--plugin-card-banner-tint: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--plugin-card-accent-rgb), 0.42) 0%,
|
||||
rgba(var(--plugin-card-accent-rgb), 0.24) 58%,
|
||||
rgba(var(--plugin-card-accent-rgb), 0.12) 100%
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.2 + var(--glass-tint-density, 0.65) * 0.34)) 0%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.11 + var(--glass-tint-density, 0.65) * 0.2)) 58%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.055 + var(--glass-tint-density, 0.65) * 0.1)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -842,14 +868,14 @@ html[data-theme='glass'] {
|
||||
// 磨砂材质透光更强,染色需要同步减弱以免头部压过卡片本体。
|
||||
&[data-glass-appearance='frosted'] .plugin-card__banner {
|
||||
--plugin-card-banner-scrim: linear-gradient(
|
||||
rgba(11, 19, 34, calc(0.24 - var(--glass-transparency, 0.5) * 0.12)) 0%,
|
||||
rgba(11, 19, 34, calc(0.34 - var(--glass-transparency, 0.5) * 0.14)) 100%
|
||||
rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.86) * 0.09)) 0%,
|
||||
rgba(11, 19, 34, calc(0.14 + var(--glass-surface-density, 0.86) * 0.145)) 100%
|
||||
);
|
||||
--plugin-card-banner-tint: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--plugin-card-accent-rgb), 0.32) 0%,
|
||||
rgba(var(--plugin-card-accent-rgb), 0.18) 58%,
|
||||
rgba(var(--plugin-card-accent-rgb), 0.08) 100%
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.15 + var(--glass-tint-density, 0.65) * 0.2)) 0%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.07 + var(--glass-tint-density, 0.65) * 0.13)) 58%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.03 + var(--glass-tint-density, 0.65) * 0.06)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -857,15 +883,25 @@ html[data-theme='glass'] {
|
||||
&[data-glass-appearance='tinted'] .plugin-card__banner {
|
||||
--plugin-card-banner-tint: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, rgba(var(--plugin-card-accent-rgb), 0.42) 74%, rgba(var(--v-theme-primary), 0.42)) 0%,
|
||||
color-mix(in srgb, rgba(var(--plugin-card-accent-rgb), 0.24) 74%, rgba(var(--v-theme-primary), 0.24)) 58%,
|
||||
rgba(var(--plugin-card-accent-rgb), 0.12) 100%
|
||||
color-mix(
|
||||
in srgb,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) 74%,
|
||||
rgba(var(--v-theme-primary), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33))
|
||||
)
|
||||
0%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) 74%,
|
||||
rgba(var(--v-theme-primary), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2))
|
||||
)
|
||||
58%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
// 文件夹卡片保留用户自选渐变作为色相,只降低不透明度让卡片本体的玻璃透出来。
|
||||
.plugin-folder-card__bg {
|
||||
opacity: calc(0.5 - var(--glass-transparency, 0.5) * 0.14);
|
||||
opacity: calc(0.34 + var(--glass-surface-density, 0.62) * 0.145);
|
||||
}
|
||||
|
||||
.plugin-folder-card__bg::after {
|
||||
@@ -879,8 +915,8 @@ html[data-theme='glass'] {
|
||||
|
||||
.plugin-folder-card__overlay {
|
||||
background: linear-gradient(
|
||||
rgba(11, 19, 34, calc(0.34 - var(--glass-transparency, 0.5) * 0.14)) 0%,
|
||||
rgba(11, 19, 34, calc(0.46 - var(--glass-transparency, 0.5) * 0.16)) 100%
|
||||
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.115)) 0%,
|
||||
rgba(11, 19, 34, calc(0.28 + var(--glass-surface-density, 0.62) * 0.16)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1039,6 +1075,11 @@ html[data-theme='glass'] {
|
||||
border-color: var(--glass-border-hover) !important;
|
||||
box-shadow: var(--glass-shadow-hover) !important;
|
||||
}
|
||||
|
||||
// 玻璃主题的上浮反馈只增强边缘受光,避免外投影与相邻材质叠成宽暗带。
|
||||
.app-hover-lift-card:is(:hover, .app-hover-lift-card--hovering) {
|
||||
box-shadow: var(--glass-dashboard-shadow-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):active {
|
||||
@@ -1123,7 +1164,6 @@ html[data-theme='glass'] {
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 180ms ease-out;
|
||||
}
|
||||
|
||||
.glass-optical-layer--fixed {
|
||||
@@ -1152,6 +1192,7 @@ html[data-theme='glass']:is(
|
||||
)[data-glass-renderer-state='ready'] {
|
||||
--glass-surface-backdrop-filter: none;
|
||||
--glass-raised-backdrop-filter: none;
|
||||
--glass-fixed-shell-backdrop-filter: none;
|
||||
--glass-navbar-backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
}
|
||||
|
||||
@@ -1160,8 +1201,6 @@ 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: 0px;
|
||||
--glass-overlay-saturate: 100%;
|
||||
}
|
||||
|
||||
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
GLASS_OPTICAL_MOTION_MAX_SCALE,
|
||||
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
|
||||
getAvailableGlassOpticalPresets,
|
||||
getGlassCssFrostBlur,
|
||||
getGlassCoverScale,
|
||||
getGlassMaterialResponse,
|
||||
getGlassOpticalCssTransmissionBrightness,
|
||||
getGlassOpticalDecay,
|
||||
getGlassOpticalBufferSize,
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
getGlassOpticalMotionExpansion,
|
||||
getGlassOpticalMotionStrengthScale,
|
||||
getGlassOpticalPresetParameters,
|
||||
getGlassOpticalPresetParametersWithOverrides,
|
||||
getGlassOpticalPresetKey,
|
||||
getGlassOpticalReflectionStrengthScale,
|
||||
getGlassOpticalRenderProfile,
|
||||
getGlassOpticalTransparency,
|
||||
@@ -57,7 +61,9 @@ describe('glass optics geometry', () => {
|
||||
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
|
||||
getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25),
|
||||
)
|
||||
expect(getGlassOpticalMotionExpansion(50)).toBe(0)
|
||||
expect(getGlassOpticalMotionExpansion(0)).toBe(0)
|
||||
expect(getGlassOpticalMotionExpansion(50)).toBeGreaterThan(0.3)
|
||||
expect(getGlassOpticalMotionExpansion(50)).toBeLessThan(0.4)
|
||||
expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4)
|
||||
expect(getGlassOpticalMotionExpansion(100)).toBe(1)
|
||||
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
|
||||
@@ -87,12 +93,12 @@ describe('glass optics geometry', () => {
|
||||
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
|
||||
|
||||
expect(natural).toEqual({
|
||||
deformation: 50,
|
||||
flow: 50,
|
||||
deformation: 40,
|
||||
flow: 40,
|
||||
reflection: 35,
|
||||
transmission: 70,
|
||||
translation: 50,
|
||||
transparency: 70,
|
||||
transmission: 54,
|
||||
translation: 40,
|
||||
transparency: 46,
|
||||
})
|
||||
expect(glide.translation).toBeGreaterThan(glide.deformation)
|
||||
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
|
||||
@@ -101,11 +107,166 @@ describe('glass optics geometry', () => {
|
||||
expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid'])
|
||||
})
|
||||
|
||||
it('keeps every preset dynamic parameter at the approved material calibration', () => {
|
||||
const expected = {
|
||||
clear: {
|
||||
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
|
||||
balanced: {
|
||||
natural: { deformation: 40, flow: 40, translation: 40 },
|
||||
glide: { deformation: 24, flow: 35, translation: 58 },
|
||||
liquid: { deformation: 56, flow: 61, translation: 45 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 40, flow: 40, translation: 40 },
|
||||
glide: { deformation: 26, flow: 37, translation: 59 },
|
||||
liquid: { deformation: 59, flow: 64, translation: 46 },
|
||||
},
|
||||
},
|
||||
tinted: {
|
||||
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
|
||||
balanced: {
|
||||
natural: { deformation: 42, flow: 40, translation: 40 },
|
||||
glide: { deformation: 26, flow: 35, translation: 56 },
|
||||
liquid: { deformation: 58, flow: 61, translation: 45 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 42, flow: 40, translation: 40 },
|
||||
glide: { deformation: 27, flow: 37, translation: 58 },
|
||||
liquid: { deformation: 61, flow: 64, translation: 46 },
|
||||
},
|
||||
},
|
||||
frosted: {
|
||||
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
|
||||
balanced: {
|
||||
natural: { deformation: 46, flow: 42, translation: 38 },
|
||||
glide: { deformation: 30, flow: 35, translation: 54 },
|
||||
liquid: { deformation: 62, flow: 61, translation: 42 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 48, flow: 42, translation: 38 },
|
||||
glide: { deformation: 32, flow: 37, translation: 56 },
|
||||
liquid: { deformation: 66, flow: 64, translation: 43 },
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
for (const [appearance, qualities] of Object.entries(expected)) {
|
||||
for (const [quality, presets] of Object.entries(qualities)) {
|
||||
for (const [preset, parameters] of Object.entries(presets)) {
|
||||
expect(
|
||||
getGlassOpticalPresetParameters(
|
||||
appearance as 'clear' | 'frosted' | 'tinted',
|
||||
quality as 'balanced' | 'css' | 'high',
|
||||
preset as 'glide' | 'liquid' | 'natural',
|
||||
),
|
||||
).toMatchObject(parameters as object)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('restores per-combination overrides and keeps standard quality on natural', () => {
|
||||
const key = getGlassOpticalPresetKey('tinted', 'high', 'glide')
|
||||
const override = {
|
||||
deformation: 11,
|
||||
flow: 22,
|
||||
reflection: 33,
|
||||
transmission: 44,
|
||||
translation: 55,
|
||||
transparency: 66,
|
||||
}
|
||||
|
||||
expect(key).toBe('tinted:high:glide')
|
||||
expect(getGlassOpticalPresetKey('frosted', 'css', 'liquid')).toBe('frosted:css:natural')
|
||||
expect(getGlassOpticalPresetParametersWithOverrides('tinted', 'high', 'glide', { [key]: override })).toEqual(
|
||||
override,
|
||||
)
|
||||
expect(getGlassOpticalPresetParametersWithOverrides('clear', 'balanced', 'natural', {})).toEqual(
|
||||
getGlassOpticalPresetParameters('clear', 'balanced', 'natural'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the approved transparency and transmission matrix for all effective presets', () => {
|
||||
const expected = {
|
||||
clear: {
|
||||
css: { natural: [48, 56] },
|
||||
balanced: { natural: [46, 54], glide: [56, 58], liquid: [51, 51] },
|
||||
high: { natural: [45, 53], glide: [54, 56], liquid: [50, 50] },
|
||||
},
|
||||
tinted: {
|
||||
css: { natural: [34, 54] },
|
||||
balanced: { natural: [32, 56], glide: [40, 61], liquid: [36, 53] },
|
||||
high: { natural: [30, 54], glide: [38, 59], liquid: [34, 51] },
|
||||
},
|
||||
frosted: {
|
||||
css: { natural: [31, 50] },
|
||||
balanced: { natural: [29, 52], glide: [40, 56], liquid: [34, 49] },
|
||||
high: { natural: [27, 50], glide: [38, 54], liquid: [32, 47] },
|
||||
},
|
||||
} as const
|
||||
|
||||
for (const [appearance, qualities] of Object.entries(expected)) {
|
||||
for (const [quality, presets] of Object.entries(qualities)) {
|
||||
for (const [preset, values] of Object.entries(presets)) {
|
||||
const [transparency, transmission] = values as readonly [number, number]
|
||||
|
||||
expect(
|
||||
getGlassOpticalPresetParameters(
|
||||
appearance as 'clear' | 'frosted' | 'tinted',
|
||||
quality as 'balanced' | 'css' | 'high',
|
||||
preset as 'glide' | 'liquid' | 'natural',
|
||||
),
|
||||
).toMatchObject({ transmission, transparency })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('derives independent material responses from piecewise smooth transparency anchors', () => {
|
||||
expect(getGlassMaterialResponse('clear', 0)).toMatchObject({
|
||||
backgroundVisibility: 0.18,
|
||||
surfaceDensity: 1,
|
||||
})
|
||||
expect(getGlassMaterialResponse('tinted', 50)).toMatchObject({
|
||||
backgroundVisibility: 0.48,
|
||||
tintDensity: 0.65,
|
||||
})
|
||||
const frostedLow = getGlassMaterialResponse('frosted', 20)
|
||||
expect(frostedLow).toMatchObject({
|
||||
backgroundVisibility: 0.14,
|
||||
frostBlurScale: 1.48,
|
||||
surfaceDensity: 0.96,
|
||||
})
|
||||
expect(frostedLow.frostDetailLevel).toBeCloseTo(0.1)
|
||||
expect(getGlassMaterialResponse('frosted', 100)).toMatchObject({
|
||||
backgroundVisibility: 0.88,
|
||||
frostBlurScale: 0.52,
|
||||
frostDetailLevel: 0.9,
|
||||
surfaceDensity: 0.4,
|
||||
})
|
||||
expect(getGlassCssFrostBlur(0)).toEqual({ raised: 84, surface: 64 })
|
||||
expect(getGlassCssFrostBlur(50)).toEqual({ raised: 62, surface: 44 })
|
||||
expect(getGlassCssFrostBlur(100)).toEqual({ raised: 26, surface: 16 })
|
||||
|
||||
const samples = [0, 10, 20, 35, 50, 60, 70, 78, 85, 92, 100].map(value =>
|
||||
getGlassMaterialResponse('frosted', value),
|
||||
)
|
||||
expect(
|
||||
samples.every(
|
||||
(sample, index) =>
|
||||
index === 0 ||
|
||||
(sample.backgroundVisibility > samples[index - 1].backgroundVisibility &&
|
||||
sample.frostDetailLevel > samples[index - 1].frostDetailLevel &&
|
||||
sample.surfaceDensity < samples[index - 1].surfaceDensity),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns preset copies so previews cannot mutate the shared matrix', () => {
|
||||
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
|
||||
first.translation = 0
|
||||
|
||||
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(72)
|
||||
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(58)
|
||||
})
|
||||
|
||||
it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => {
|
||||
@@ -346,8 +507,8 @@ describe('glass optics geometry', () => {
|
||||
})
|
||||
|
||||
it('crossfades active surface weights monotonically', () => {
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 1, outgoing: 1 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 1, outgoing: 0.5 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, getGlassWallpaperToneProfile } from '@/utils/glassWallpaperTone'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||
getGlassWallpaperToneProfile,
|
||||
loadGlassWallpaperTone,
|
||||
takeGlassWallpaperDecodedSource,
|
||||
} from '@/utils/glassWallpaperTone'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('glass wallpaper tone profile', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
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])
|
||||
|
||||
@@ -32,4 +42,127 @@ describe('glass wallpaper tone profile', () => {
|
||||
it('falls back to the neutral profile when no valid samples exist', () => {
|
||||
expect(getGlassWallpaperToneProfile([Number.NaN])).toEqual(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
||||
})
|
||||
|
||||
it('reuses the successful CORS image decode for readiness and tone analysis', async () => {
|
||||
const pixels = new Uint8ClampedArray(64 * 64 * 4)
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
pixels[offset] = 128
|
||||
pixels[offset + 1] = 128
|
||||
pixels[offset + 2] = 128
|
||||
pixels[offset + 3] = 255
|
||||
}
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: pixels })),
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
const fetchMock = vi.fn()
|
||||
let imageCount = 0
|
||||
class SuccessfulImage {
|
||||
crossOrigin = ''
|
||||
decoding = ''
|
||||
height = 64
|
||||
naturalHeight = 64
|
||||
naturalWidth = 64
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
width = 64
|
||||
|
||||
set src(_value: string) {
|
||||
imageCount += 1
|
||||
queueMicrotask(() => this.onload?.())
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('Image', SuccessfulImage)
|
||||
|
||||
const result = await loadGlassWallpaperTone('https://image.example/success.jpg')
|
||||
|
||||
expect(result.corsReady).toBe(true)
|
||||
expect(result.profile.medianLuminance).toBeCloseTo(128 / 255)
|
||||
expect(imageCount).toBe(1)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(takeGlassWallpaperDecodedSource('https://image.example/success.jpg')?.profile).toEqual(result.profile)
|
||||
expect(takeGlassWallpaperDecodedSource('https://image.example/success.jpg')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('repairs a polluted browser cache before retrying the CORS image decode', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([128, 128, 128, 255]) })),
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
let imageCount = 0
|
||||
class RecoverableImage {
|
||||
crossOrigin = ''
|
||||
decoding = ''
|
||||
height = 64
|
||||
naturalHeight = 64
|
||||
naturalWidth = 64
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
width = 64
|
||||
|
||||
set src(_value: string) {
|
||||
imageCount += 1
|
||||
const succeeds = imageCount > 1
|
||||
queueMicrotask(() => (succeeds ? this.onload?.() : this.onerror?.()))
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('Image', RecoverableImage)
|
||||
|
||||
const result = await loadGlassWallpaperTone('https://image.example/recovered.jpg')
|
||||
|
||||
expect(result.corsReady).toBe(true)
|
||||
expect(imageCount).toBe(2)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
new URL('https://image.example/recovered.jpg'),
|
||||
expect.objectContaining({ cache: 'reload', credentials: 'omit', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('retries a transiently failed CORS decode instead of caching the fallback', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([128, 128, 128, 255]) })),
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(new Blob()),
|
||||
ok: false,
|
||||
}),
|
||||
)
|
||||
let imageCount = 0
|
||||
class TransientImage {
|
||||
crossOrigin = ''
|
||||
decoding = ''
|
||||
height = 64
|
||||
naturalHeight = 64
|
||||
naturalWidth = 64
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
width = 64
|
||||
|
||||
set src(_value: string) {
|
||||
imageCount += 1
|
||||
queueMicrotask(() => (imageCount === 1 ? this.onerror?.() : this.onload?.()))
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', TransientImage)
|
||||
|
||||
const failed = await loadGlassWallpaperTone('https://image.example/transient.jpg')
|
||||
const recovered = await loadGlassWallpaperTone('https://image.example/transient.jpg')
|
||||
|
||||
expect(failed.corsReady).toBe(false)
|
||||
expect(recovered.corsReady).toBe(true)
|
||||
expect(imageCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ export const GLASS_OPTICAL_REFERENCE_STRENGTH = 70
|
||||
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
|
||||
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
|
||||
export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural'
|
||||
export type GlassOpticalPresetKey = `${GlassAppearance}:${GlassOpticalCapability}:${GlassOpticalPreset}`
|
||||
export type GlassOpticalPresetOverrides = Partial<Record<GlassOpticalPresetKey, GlassOpticalParameters>>
|
||||
export type GlassOpticalQuality = 'balanced' | 'high'
|
||||
export type GlassCornerRadii = [number, number, number, number]
|
||||
|
||||
@@ -31,6 +33,19 @@ export interface GlassOpticalParameters {
|
||||
transparency: number
|
||||
}
|
||||
|
||||
export interface GlassMaterialResponse {
|
||||
/** 真实壁纸在表面材质后的感知可见程度。 */
|
||||
backgroundVisibility: number
|
||||
/** 磨砂预滤纹理允许保留的高频细节比例。 */
|
||||
frostDetailLevel: number
|
||||
/** 标准档 CSS 磨砂半径相对既有 40px 基线的缩放。 */
|
||||
frostBlurScale: number
|
||||
/** 表面遮罩对真实壁纸的覆盖密度。 */
|
||||
surfaceDensity: number
|
||||
/** 色调材质的主体染色密度。 */
|
||||
tintDensity: number
|
||||
}
|
||||
|
||||
export interface GlassInteractionPoint {
|
||||
/** 指针或触点相对视口的横坐标。 */
|
||||
x: number
|
||||
@@ -128,59 +143,57 @@ export function normalizeGlassOpticalStrength(value: unknown) {
|
||||
return Math.min(GLASS_OPTICAL_STRENGTH_MAX, Math.max(GLASS_OPTICAL_STRENGTH_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
const GLASS_OPTICAL_PRESET_MATRIX: Record<
|
||||
GlassAppearance,
|
||||
Record<GlassOpticalCapability, Record<GlassOpticalPreset, GlassOpticalParameters>>
|
||||
> = {
|
||||
type GlassOpticalPresetSet = Record<GlassOpticalPreset, GlassOpticalParameters>
|
||||
type GlassOpticalCapabilityPresets = {
|
||||
balanced: GlassOpticalPresetSet
|
||||
css: Pick<GlassOpticalPresetSet, 'natural'>
|
||||
high: GlassOpticalPresetSet
|
||||
}
|
||||
|
||||
const GLASS_OPTICAL_PRESET_MATRIX: Record<GlassAppearance, GlassOpticalCapabilityPresets> = {
|
||||
clear: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
|
||||
glide: { deformation: 28, flow: 42, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
|
||||
liquid: { deformation: 72, flow: 78, reflection: 38, transmission: 66, translation: 56, transparency: 74 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 56, translation: 40, transparency: 48 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
|
||||
glide: { deformation: 30, flow: 44, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
|
||||
liquid: { deformation: 70, flow: 76, reflection: 36, transmission: 66, translation: 56, transparency: 74 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 46 },
|
||||
glide: { deformation: 24, flow: 35, reflection: 29, transmission: 58, translation: 58, transparency: 56 },
|
||||
liquid: { deformation: 56, flow: 61, reflection: 36, transmission: 51, translation: 45, transparency: 51 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 32, transmission: 70, translation: 50, transparency: 70 },
|
||||
glide: { deformation: 32, flow: 46, reflection: 28, transmission: 74, translation: 74, transparency: 80 },
|
||||
liquid: { deformation: 74, flow: 80, reflection: 35, transmission: 68, translation: 58, transparency: 76 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 32, transmission: 53, translation: 40, transparency: 45 },
|
||||
glide: { deformation: 26, flow: 37, reflection: 28, transmission: 56, translation: 59, transparency: 54 },
|
||||
liquid: { deformation: 59, flow: 64, reflection: 35, transmission: 50, translation: 46, transparency: 50 },
|
||||
},
|
||||
},
|
||||
tinted: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 38, transmission: 68, translation: 50, transparency: 46 },
|
||||
glide: { deformation: 30, flow: 42, reflection: 34, transmission: 74, translation: 70, transparency: 52 },
|
||||
liquid: { deformation: 70, flow: 76, reflection: 41, transmission: 64, translation: 54, transparency: 48 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 38, transmission: 54, translation: 40, transparency: 34 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 52, flow: 50, reflection: 38, transmission: 72, translation: 50, transparency: 46 },
|
||||
glide: { deformation: 32, flow: 44, reflection: 34, transmission: 78, translation: 70, transparency: 52 },
|
||||
liquid: { deformation: 72, flow: 76, reflection: 39, transmission: 68, translation: 56, transparency: 48 },
|
||||
natural: { deformation: 42, flow: 40, reflection: 38, transmission: 56, translation: 40, transparency: 32 },
|
||||
glide: { deformation: 26, flow: 35, reflection: 34, transmission: 61, translation: 56, transparency: 40 },
|
||||
liquid: { deformation: 58, flow: 61, reflection: 39, transmission: 53, translation: 45, transparency: 36 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 52, flow: 50, reflection: 35, transmission: 76, translation: 50, transparency: 48 },
|
||||
glide: { deformation: 34, flow: 46, reflection: 32, transmission: 82, translation: 72, transparency: 54 },
|
||||
liquid: { deformation: 76, flow: 80, reflection: 38, transmission: 72, translation: 58, transparency: 50 },
|
||||
natural: { deformation: 42, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 30 },
|
||||
glide: { deformation: 27, flow: 37, reflection: 32, transmission: 59, translation: 58, transparency: 38 },
|
||||
liquid: { deformation: 61, flow: 64, reflection: 38, transmission: 51, translation: 46, transparency: 34 },
|
||||
},
|
||||
},
|
||||
frosted: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 31, transmission: 54, translation: 50, transparency: 42 },
|
||||
glide: { deformation: 34, flow: 42, reflection: 27, transmission: 58, translation: 66, transparency: 46 },
|
||||
liquid: { deformation: 76, flow: 74, reflection: 34, transmission: 50, translation: 50, transparency: 44 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 31, transmission: 50, translation: 40, transparency: 31 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 58, flow: 52, reflection: 31, transmission: 58, translation: 48, transparency: 42 },
|
||||
glide: { deformation: 38, flow: 44, reflection: 27, transmission: 62, translation: 68, transparency: 46 },
|
||||
liquid: { deformation: 78, flow: 76, reflection: 32, transmission: 54, translation: 52, transparency: 44 },
|
||||
natural: { deformation: 46, flow: 42, reflection: 31, transmission: 52, translation: 38, transparency: 29 },
|
||||
glide: { deformation: 30, flow: 35, reflection: 27, transmission: 56, translation: 54, transparency: 40 },
|
||||
liquid: { deformation: 62, flow: 61, reflection: 32, transmission: 49, translation: 42, transparency: 34 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 60, flow: 52, reflection: 29, transmission: 62, translation: 48, transparency: 44 },
|
||||
glide: { deformation: 40, flow: 46, reflection: 25, transmission: 66, translation: 70, transparency: 48 },
|
||||
liquid: { deformation: 82, flow: 80, reflection: 31, transmission: 58, translation: 54, transparency: 46 },
|
||||
natural: { deformation: 48, flow: 42, reflection: 29, transmission: 50, translation: 38, transparency: 27 },
|
||||
glide: { deformation: 32, flow: 37, reflection: 25, transmission: 54, translation: 56, transparency: 38 },
|
||||
liquid: { deformation: 66, flow: 64, reflection: 31, transmission: 47, translation: 43, transparency: 32 },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -191,7 +204,10 @@ export function getGlassOpticalPresetParameters(
|
||||
quality: GlassOpticalCapability,
|
||||
preset: GlassOpticalPreset,
|
||||
): GlassOpticalParameters {
|
||||
return { ...GLASS_OPTICAL_PRESET_MATRIX[appearance][quality][preset] }
|
||||
const presets = GLASS_OPTICAL_PRESET_MATRIX[appearance][quality]
|
||||
const parameters = 'natural' === preset ? presets.natural : (presets as Partial<GlassOpticalPresetSet>)[preset]
|
||||
|
||||
return { ...(parameters ?? presets.natural) }
|
||||
}
|
||||
|
||||
/** 标准档只保留自然基线;实时档同时开放滑移与液态方案。 */
|
||||
@@ -199,6 +215,79 @@ export function getAvailableGlassOpticalPresets(quality: GlassOpticalCapability)
|
||||
return quality === 'css' ? ['natural'] : ['natural', 'glide', 'liquid']
|
||||
}
|
||||
|
||||
/** 生成持久化覆盖使用的稳定组合键;标准档始终归入自然方案。 */
|
||||
export function getGlassOpticalPresetKey(
|
||||
appearance: GlassAppearance,
|
||||
quality: GlassOpticalCapability,
|
||||
preset: GlassOpticalPreset,
|
||||
): GlassOpticalPresetKey {
|
||||
return `${appearance}:${quality}:${quality === 'css' ? 'natural' : preset}`
|
||||
}
|
||||
|
||||
/** 切换组合时优先恢复用户覆盖,没有覆盖才返回预设矩阵副本。 */
|
||||
export function getGlassOpticalPresetParametersWithOverrides(
|
||||
appearance: GlassAppearance,
|
||||
quality: GlassOpticalCapability,
|
||||
preset: GlassOpticalPreset,
|
||||
overrides: GlassOpticalPresetOverrides,
|
||||
) {
|
||||
const key = getGlassOpticalPresetKey(appearance, quality, preset)
|
||||
|
||||
return { ...(overrides[key] ?? getGlassOpticalPresetParameters(appearance, quality, preset)) }
|
||||
}
|
||||
|
||||
const GLASS_RESPONSE_STOPS = [0, 20, 50, 70, 85, 100] as const
|
||||
const GLASS_BACKGROUND_VISIBILITY: Record<GlassAppearance, readonly number[]> = {
|
||||
clear: [0.18, 0.3, 0.58, 0.77, 0.9, 0.96],
|
||||
tinted: [0.08, 0.2, 0.48, 0.7, 0.84, 0.92],
|
||||
frosted: [0.04, 0.14, 0.35, 0.6, 0.78, 0.88],
|
||||
}
|
||||
const GLASS_SURFACE_DENSITY: Record<GlassAppearance, readonly number[]> = {
|
||||
clear: [1, 0.88, 0.62, 0.42, 0.26, 0.18],
|
||||
tinted: [1, 0.92, 0.72, 0.52, 0.39, 0.3],
|
||||
frosted: [1, 0.96, 0.86, 0.68, 0.5, 0.4],
|
||||
}
|
||||
const GLASS_TINT_DENSITY = [1, 0.9, 0.65, 0.48, 0.36, 0.28] as const
|
||||
const GLASS_FROST_DENSITY = [1, 0.9, 0.7, 0.4, 0.18, 0.1] as const
|
||||
|
||||
/** 在相邻业务锚点之间使用零斜率边界插值,避免滑杆经过锚点时出现视觉折线。 */
|
||||
function interpolateGlassResponse(value: unknown, anchors: readonly number[]) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const upperIndex = GLASS_RESPONSE_STOPS.findIndex(stop => normalized <= stop)
|
||||
if (upperIndex <= 0) return anchors[0]
|
||||
|
||||
const lowerIndex = upperIndex - 1
|
||||
const lowerStop = GLASS_RESPONSE_STOPS[lowerIndex]
|
||||
const upperStop = GLASS_RESPONSE_STOPS[upperIndex]
|
||||
const linearProgress = (normalized - lowerStop) / (upperStop - lowerStop)
|
||||
const smoothProgress = linearProgress * linearProgress * (3 - 2 * linearProgress)
|
||||
|
||||
return anchors[lowerIndex] + (anchors[upperIndex] - anchors[lowerIndex]) * smoothProgress
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个通透度输入派生互不混用的材质响应;tone、曝光和透射亮度不在此处计算。
|
||||
*/
|
||||
export function getGlassMaterialResponse(appearance: GlassAppearance, value: unknown): GlassMaterialResponse {
|
||||
const frostDensity = interpolateGlassResponse(value, GLASS_FROST_DENSITY)
|
||||
|
||||
return {
|
||||
backgroundVisibility: interpolateGlassResponse(value, GLASS_BACKGROUND_VISIBILITY[appearance]),
|
||||
frostBlurScale: 0.4 + frostDensity * 1.2,
|
||||
frostDetailLevel: 1 - frostDensity,
|
||||
surfaceDensity: interpolateGlassResponse(value, GLASS_SURFACE_DENSITY[appearance]),
|
||||
tintDensity: interpolateGlassResponse(value, GLASS_TINT_DENSITY),
|
||||
}
|
||||
}
|
||||
|
||||
/** 标准档磨砂使用独立的 surface/raised 半径锚点,不借用背景亮度制造厚度。 */
|
||||
export function getGlassCssFrostBlur(value: unknown) {
|
||||
return {
|
||||
raised: interpolateGlassResponse(value, [84, 76, 62, 46, 34, 26]),
|
||||
surface: interpolateGlassResponse(value, [64, 58, 44, 30, 22, 16]),
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
|
||||
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
|
||||
if (duration <= 0 || elapsed >= duration) return 1
|
||||
@@ -258,15 +347,11 @@ export function getGlassOpticalFlowStrengthScale(value: unknown) {
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_FLOW_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 高于默认值的流动强度逐步扩大作用范围,低区间不会意外改变既有空间尺度。 */
|
||||
/** 流动强度在完整滑杆区间连续控制轨迹范围,低值也能明显收紧空间足迹。 */
|
||||
export function getGlassOpticalMotionExpansion(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const highRangeProgress = Math.max(
|
||||
0,
|
||||
(normalized - GLASS_OPTICAL_STRENGTH_DEFAULT) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_STRENGTH_DEFAULT),
|
||||
)
|
||||
|
||||
return highRangeProgress ** 1.55
|
||||
return (normalized / GLASS_OPTICAL_STRENGTH_MAX) ** 1.4
|
||||
}
|
||||
|
||||
/** 最大几何形变只由质量档约束;流动滑杆改变覆盖与连续性,不继续拉伸背景内容。 */
|
||||
@@ -442,13 +527,13 @@ export function getGlassOpticalWakeDirection(
|
||||
return directionDot < Math.cos((55 * Math.PI) / 180) ? nextDirection : currentDirection
|
||||
}
|
||||
|
||||
/** 活动表面立即可感知,随后与离场表面完成短时单调交叉过渡。 */
|
||||
/** 新活动表面立即接管输入,离场表面只做短时单调淡出。 */
|
||||
export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) {
|
||||
const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration)))
|
||||
const eased = progress * progress * (3 - 2 * progress)
|
||||
|
||||
return {
|
||||
incoming: 0.35 + eased * 0.65,
|
||||
incoming: 1,
|
||||
outgoing: 1 - eased,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { preloadCorsImage } from '@/@core/utils/corsImage'
|
||||
|
||||
export interface GlassWallpaperToneProfile {
|
||||
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
|
||||
exposure: number
|
||||
@@ -7,7 +9,24 @@ export interface GlassWallpaperToneProfile {
|
||||
medianLuminance: number
|
||||
}
|
||||
|
||||
/** 一次图片解码同时给出 WebGL 可读性与壁纸曝光分析结果。 */
|
||||
export interface GlassWallpaperToneLoadResult {
|
||||
/** 当前 URL 已按匿名 CORS 模式完成解码,可安全交给 WebGL。 */
|
||||
corsReady: boolean
|
||||
/** 与 DOM 背景和 renderer 共用的有限曝光 profile。 */
|
||||
profile: GlassWallpaperToneProfile
|
||||
}
|
||||
|
||||
/** 交给 renderer 消费的一次性已解码壁纸源。 */
|
||||
export interface GlassWallpaperDecodedSource {
|
||||
/** 已完成匿名 CORS 解码的图片。 */
|
||||
image: HTMLImageElement
|
||||
/** 与该图片同一次解码得到的曝光 profile。 */
|
||||
profile: GlassWallpaperToneProfile
|
||||
}
|
||||
|
||||
const ANALYSIS_MAX_EDGE = 64
|
||||
const DECODED_SOURCE_CACHE_LIMIT = 3
|
||||
const PROFILE_CACHE_LIMIT = 32
|
||||
const PROFILE_LOAD_TIMEOUT_MS = 3000
|
||||
const EXPOSURE_MIN = 0.88
|
||||
@@ -21,7 +40,8 @@ export const DEFAULT_GLASS_WALLPAPER_TONE_PROFILE: GlassWallpaperToneProfile = {
|
||||
medianLuminance: MEDIAN_TARGET,
|
||||
}
|
||||
|
||||
const profileCache = new Map<string, Promise<GlassWallpaperToneProfile>>()
|
||||
const profileCache = new Map<string, Promise<GlassWallpaperToneLoadResult>>()
|
||||
const decodedSourceCache = new Map<string, GlassWallpaperDecodedSource>()
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
@@ -86,7 +106,7 @@ export function analyzeGlassWallpaperTone(image: CanvasImageSource, width: numbe
|
||||
}
|
||||
}
|
||||
|
||||
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile>) {
|
||||
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneLoadResult>) {
|
||||
if (profileCache.size >= PROFILE_CACHE_LIMIT) {
|
||||
const oldestKey = profileCache.keys().next().value
|
||||
if (oldestKey) profileCache.delete(oldestKey)
|
||||
@@ -96,37 +116,105 @@ function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 只保留 current/previous/prepared 三个候选,避免完整解码图片变成长期内存缓存。 */
|
||||
function rememberDecodedSource(url: string, source: GlassWallpaperDecodedSource) {
|
||||
decodedSourceCache.delete(url)
|
||||
decodedSourceCache.set(url, source)
|
||||
|
||||
while (decodedSourceCache.size > DECODED_SOURCE_CACHE_LIMIT) {
|
||||
const oldestKey = decodedSourceCache.keys().next().value
|
||||
if (oldestKey) decodedSourceCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 DOM 背景层加载可读像素。跨域来源不支持 CORS 时回落中性 profile,
|
||||
* 避免亮度分析阻断壁纸本身的 CSS 显示能力。
|
||||
* renderer 取得已完成 tone 分析的图片后立即移出缓存。
|
||||
* fixed 与 scroll context 后续通过 renderer 自身的 CPU source cache 共享缩放结果。
|
||||
*/
|
||||
export function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
|
||||
if (!url || typeof Image === 'undefined') return Promise.resolve({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
|
||||
export function takeGlassWallpaperDecodedSource(url: string): GlassWallpaperDecodedSource | undefined {
|
||||
const source = decodedSourceCache.get(url)
|
||||
if (source) decodedSourceCache.delete(url)
|
||||
|
||||
const cached = profileCache.get(url)
|
||||
if (cached) return cached
|
||||
return source
|
||||
}
|
||||
|
||||
const profile = new Promise<GlassWallpaperToneProfile>(resolve => {
|
||||
function loadCorsReadableToneProfile(url: string): Promise<GlassWallpaperToneLoadResult> {
|
||||
return new Promise(resolve => {
|
||||
const image = new Image()
|
||||
let settled = false
|
||||
const finish = (result: GlassWallpaperToneProfile) => {
|
||||
const finish = (result: GlassWallpaperToneLoadResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timeout)
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(
|
||||
() => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }),
|
||||
() => finish({ corsReady: false, profile: { ...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.onload = () => {
|
||||
const profile = analyzeGlassWallpaperTone(
|
||||
image,
|
||||
image.naturalWidth || image.width,
|
||||
image.naturalHeight || image.height,
|
||||
)
|
||||
rememberDecodedSource(url, { image, profile })
|
||||
finish({
|
||||
corsReady: true,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
image.onerror = () =>
|
||||
finish({
|
||||
corsReady: false,
|
||||
profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
|
||||
})
|
||||
image.src = url
|
||||
})
|
||||
|
||||
return rememberProfile(url, profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以一次匿名图片解码同时完成 WebGL 可读性检查和曝光分析。
|
||||
* 只有旧的非 CORS 缓存污染读取时才强制重新验证,再进行一次解码。
|
||||
*/
|
||||
export function loadGlassWallpaperTone(url: string): Promise<GlassWallpaperToneLoadResult> {
|
||||
if (!url || typeof Image === 'undefined') {
|
||||
return Promise.resolve({
|
||||
corsReady: false,
|
||||
profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
|
||||
})
|
||||
}
|
||||
|
||||
const cached = profileCache.get(url)
|
||||
if (cached) return cached
|
||||
|
||||
const profile = (async () => {
|
||||
const initial = await loadCorsReadableToneProfile(url)
|
||||
if (initial.corsReady || !(await preloadCorsImage(url))) return initial
|
||||
|
||||
return loadCorsReadableToneProfile(url)
|
||||
})()
|
||||
|
||||
rememberProfile(url, profile)
|
||||
void profile.then(
|
||||
result => {
|
||||
if (!result.corsReady && profileCache.get(url) === profile) profileCache.delete(url)
|
||||
},
|
||||
() => {
|
||||
if (profileCache.get(url) === profile) profileCache.delete(url)
|
||||
},
|
||||
)
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 DOM 背景层加载可读像素。跨域来源不支持 CORS 时回落中性 profile,
|
||||
* 避免亮度分析阻断壁纸本身的 CSS 显示能力。
|
||||
*/
|
||||
export async function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
|
||||
return (await loadGlassWallpaperTone(url)).profile
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user