mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 01:06:41 +08:00
feat(glass): refine material response and rendering lifecycle (#596)
This commit is contained in:
@@ -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'
|
import ColorThief from 'colorthief'
|
||||||
|
export { preloadCorsImage } from './corsImage'
|
||||||
|
|
||||||
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
||||||
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+193
-94
@@ -7,7 +7,7 @@ import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
|||||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||||
import { SupportedLocale } from '@/types/i18n'
|
import { SupportedLocale } from '@/types/i18n'
|
||||||
import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||||
import { preloadCorsImage, preloadImage } from './@core/utils/image'
|
import { preloadImage } from './@core/utils/image'
|
||||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||||
@@ -27,6 +27,7 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
|
|||||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||||
|
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
|
||||||
import {
|
import {
|
||||||
BACKGROUND_ROTATION_GRACE_MS,
|
BACKGROUND_ROTATION_GRACE_MS,
|
||||||
createBackgroundCandidateOrderResolver,
|
createBackgroundCandidateOrderResolver,
|
||||||
@@ -45,7 +46,7 @@ import {
|
|||||||
} from '@/utils/loginPresentation'
|
} from '@/utils/loginPresentation'
|
||||||
import {
|
import {
|
||||||
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||||
loadGlassWallpaperToneProfile,
|
loadGlassWallpaperTone,
|
||||||
type GlassWallpaperToneProfile,
|
type GlassWallpaperToneProfile,
|
||||||
} from '@/utils/glassWallpaperTone'
|
} from '@/utils/glassWallpaperTone'
|
||||||
|
|
||||||
@@ -97,6 +98,17 @@ function resolveInitialThemeName(themePreference: string) {
|
|||||||
return resolveThemeName(themePreference)
|
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 vuetifyTheme = useTheme()
|
||||||
const { global: globalTheme } = vuetifyTheme
|
const { global: globalTheme } = vuetifyTheme
|
||||||
@@ -135,12 +147,22 @@ const globalSettingsStore = useGlobalSettingsStore()
|
|||||||
// 背景图片
|
// 背景图片
|
||||||
const backgroundImages = ref<string[]>([])
|
const backgroundImages = ref<string[]>([])
|
||||||
const backgroundLayers = ref(createLoginBackgroundLayers())
|
const backgroundLayers = ref(createLoginBackgroundLayers())
|
||||||
|
const backgroundDisplayImages = ref<Record<string, string>>({})
|
||||||
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
|
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
|
||||||
const activeImageIndex = ref(0)
|
const activeImageIndex = ref(0)
|
||||||
const previousImageIndex = ref<number | null>(null)
|
const previousImageIndex = ref<number | null>(null)
|
||||||
const isBackgroundCrossfading = ref(false)
|
const isBackgroundCrossfading = ref(false)
|
||||||
const backgroundCrossfadeStartedAt = ref(0)
|
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 resolveBackgroundCandidateOrder = createBackgroundCandidateOrderResolver()
|
||||||
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
|
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
|
||||||
const preferredMotion = usePreferredReducedMotion()
|
const preferredMotion = usePreferredReducedMotion()
|
||||||
@@ -213,13 +235,16 @@ const shouldLoadBackgroundImages = computed(
|
|||||||
)
|
)
|
||||||
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
|
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
|
||||||
const renderedBackgroundLayers = computed(() => backgroundLayers.value)
|
const renderedBackgroundLayers = computed(() => backgroundLayers.value)
|
||||||
const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value))
|
const getOpticalBackgroundImage = (imageUrl: string) => imageUrl
|
||||||
const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value))
|
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 previousOpticalBackgroundImage = computed(() => {
|
||||||
const previousIndex = previousImageIndex.value
|
const previousIndex = previousImageIndex.value
|
||||||
if (previousIndex === null) return ''
|
if (previousIndex === null) return ''
|
||||||
|
|
||||||
return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
|
return getPreparedOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
|
||||||
})
|
})
|
||||||
const shouldRenderGlassOpticalLayer = computed(
|
const shouldRenderGlassOpticalLayer = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -242,12 +267,12 @@ const shouldRenderGlobalBlurLayer = computed(
|
|||||||
let backgroundRetryTimer: number | null = null
|
let backgroundRetryTimer: number | null = null
|
||||||
let backgroundRequestController: AbortController | null = null
|
let backgroundRequestController: AbortController | null = null
|
||||||
let backgroundCrossfadeTimer: number | 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 authenticatedStateTimer: number | null = null
|
||||||
let backgroundLoadVersion = 0
|
let backgroundLoadVersion = 0
|
||||||
let backgroundRecoveryAttemptedVersion = -1
|
let backgroundRecoveryAttemptedVersion = -1
|
||||||
let backgroundRotationVersion = 0
|
let backgroundRotationVersion = 0
|
||||||
|
let backgroundPreloadIdleHandle: number | null = null
|
||||||
|
let backgroundPreloadTimer: number | null = null
|
||||||
|
|
||||||
// 读取并同步透明主题背景设置到根组件响应式状态。
|
// 读取并同步透明主题背景设置到根组件响应式状态。
|
||||||
function applyTransparentBackgroundSettings() {
|
function applyTransparentBackgroundSettings() {
|
||||||
@@ -265,27 +290,15 @@ function handleTransparencySettingsChanged(event: Event) {
|
|||||||
transparencyGlassQuality.value = glassQuality
|
transparencyGlassQuality.value = glassQuality
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在壁纸可见前准备稳健曝光;不可读跨域图片回落中性 profile,不阻断 CSS 背景。 */
|
|
||||||
async function ensureBackgroundToneProfile(imageUrl: string) {
|
|
||||||
if (!imageUrl || !isGlassTheme.value) return DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
|
|
||||||
|
|
||||||
const profile = await loadGlassWallpaperToneProfile(getOpticalBackgroundImage(imageUrl))
|
|
||||||
backgroundToneProfiles.value = {
|
|
||||||
...backgroundToneProfiles.value,
|
|
||||||
[imageUrl]: profile,
|
|
||||||
}
|
|
||||||
|
|
||||||
return profile
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 让稳定双槽位分别携带当前壁纸的曝光,交叉淡化期间不共享新图参数。 */
|
/** 让稳定双槽位分别携带当前壁纸的曝光,交叉淡化期间不共享新图参数。 */
|
||||||
function getBackgroundLayerStyle(layer: LoginBackgroundLayer) {
|
function getBackgroundLayerStyle(layer: LoginBackgroundLayer) {
|
||||||
const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
|
const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
|
||||||
const appearance = effectiveGlassSettings.value.glassAppearance
|
const appearance = effectiveGlassSettings.value.glassAppearance
|
||||||
const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86
|
const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86
|
||||||
|
const displayUrl = isGlassTheme.value ? getPreparedBackgroundImage(layer.url) : layer.url
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'backgroundImage': layer.url ? `url(${layer.url})` : undefined,
|
'backgroundImage': displayUrl ? `url(${displayUrl})` : undefined,
|
||||||
'--glass-wallpaper-brightness': String(materialExposure * profile.exposure),
|
'--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 完成下一张纹理上传。 */
|
/** 等待两个 WebGL 呈现 context 完成下一张纹理上传。 */
|
||||||
function prepareOpticalWallpaper(url: string) {
|
async function prepareOpticalWallpaper(url: string) {
|
||||||
if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) {
|
if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) {
|
||||||
return Promise.resolve(true)
|
return { ready: true, revision: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
settlePendingOpticalWallpaper(false)
|
const ready = requestOpticalWallpaperPreparation(url)
|
||||||
pendingOpticalBackgroundImage.value = url
|
const revision = pendingOpticalWallpaperRevision.value
|
||||||
|
|
||||||
return new Promise<boolean>(resolve => {
|
return { ready: await ready, revision }
|
||||||
pendingOpticalWallpaperResolve = resolve
|
|
||||||
pendingOpticalWallpaperTimer = window.setTimeout(() => settlePendingOpticalWallpaper(false), 10000)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只接受当前待切换 URL 的 renderer 就绪回执。 */
|
/** 只接受当前待切换 URL 的 renderer 就绪回执。 */
|
||||||
function handleOpticalWallpaperPrepared(url: string) {
|
function handleOpticalWallpaperPrepared(url: string, revision: number) {
|
||||||
if (url === pendingOpticalBackgroundImage.value) settlePendingOpticalWallpaper(true)
|
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
|
if (nextIndex === activeImageIndex.value) return
|
||||||
|
|
||||||
clearBackgroundCrossfadeTimer()
|
clearBackgroundCrossfadeTimer()
|
||||||
backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '')
|
backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '')
|
||||||
previousImageIndex.value = activeImageIndex.value
|
previousImageIndex.value = activeImageIndex.value
|
||||||
isBackgroundCrossfading.value = true
|
isBackgroundCrossfading.value = true
|
||||||
backgroundCrossfadeStartedAt.value = performance.now()
|
backgroundCrossfadeStartedAt.value = startedAt
|
||||||
activeImageIndex.value = nextIndex
|
activeImageIndex.value = nextIndex
|
||||||
backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value)
|
backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value)
|
||||||
|
const remainingDuration = Math.max(0, BACKGROUND_CROSSFADE_DURATION_MS - (performance.now() - startedAt))
|
||||||
backgroundCrossfadeTimer = window.setTimeout(() => {
|
backgroundCrossfadeTimer = window.setTimeout(() => {
|
||||||
previousImageIndex.value = null
|
previousImageIndex.value = null
|
||||||
isBackgroundCrossfading.value = false
|
isBackgroundCrossfading.value = false
|
||||||
backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value)
|
backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value)
|
||||||
backgroundCrossfadeTimer = null
|
backgroundCrossfadeTimer = null
|
||||||
}, BACKGROUND_CROSSFADE_DURATION_MS)
|
}, remainingDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。
|
// 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。
|
||||||
@@ -592,31 +610,86 @@ function preloadNextBackgroundImage() {
|
|||||||
void preloadBackgroundCandidate(backgroundImages.value[nextIndex])
|
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) {
|
async function preloadBackgroundCandidate(imageUrl: string) {
|
||||||
const toneProfile = isGlassTheme.value
|
recordGlassLaunchTiming('wallpaper-source-requested', imageUrl)
|
||||||
? ensureBackgroundToneProfile(imageUrl)
|
if (!isGlassTheme.value) return preloadImage(imageUrl)
|
||||||
: Promise.resolve(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
|
||||||
if (!shouldRenderGlassOpticalLayer.value) {
|
|
||||||
const [available] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
|
||||||
|
|
||||||
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)
|
backgroundDisplayImages.value = {
|
||||||
const opticalReady = await preloadCorsImage(opticalUrl)
|
...backgroundDisplayImages.value,
|
||||||
if (!opticalReady) {
|
[imageUrl]: imageUrl,
|
||||||
const [displayReady] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
|
||||||
|
|
||||||
return displayReady
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [displayReady] = await Promise.all([
|
const ready = await preloadImage(imageUrl)
|
||||||
opticalUrl === imageUrl ? Promise.resolve(true) : preloadImage(imageUrl),
|
recordGlassLaunchTiming(ready ? 'wallpaper-source-ready' : 'wallpaper-source-failed', imageUrl)
|
||||||
toneProfile,
|
return ready
|
||||||
])
|
|
||||||
|
|
||||||
return displayReady
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 背景图片轮换函数
|
// 背景图片轮换函数
|
||||||
@@ -630,20 +703,35 @@ async function rotateBackgroundImage() {
|
|||||||
|
|
||||||
const nextIndex = (activeIndex + offset) % backgroundImages.value.length
|
const nextIndex = (activeIndex + offset) % backgroundImages.value.length
|
||||||
const nextImage = backgroundImages.value[nextIndex]
|
const nextImage = backgroundImages.value[nextIndex]
|
||||||
const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined
|
let opticalRevision = 0
|
||||||
if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue
|
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({
|
const imagesReady = await preloadBackgroundRotationImages({
|
||||||
displayUrl: nextImage,
|
displayUrl: isGlassTheme.value ? getPreparedBackgroundImage(nextImage) : nextImage,
|
||||||
opticalUrl: opticalImage,
|
opticalUrl: opticalImage,
|
||||||
preload: preloadImage,
|
preload: preloadImage,
|
||||||
})
|
})
|
||||||
if (!imagesReady) continue
|
if (!imagesReady) continue
|
||||||
await ensureBackgroundToneProfile(nextImage)
|
|
||||||
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
||||||
|
|
||||||
|
if (opticalImage) {
|
||||||
|
if (!(await requestOpticalWallpaperActivation(nextIndex, opticalRevision))) continue
|
||||||
|
} else {
|
||||||
activateBackgroundImage(nextIndex)
|
activateBackgroundImage(nextIndex)
|
||||||
preloadNextBackgroundImage()
|
}
|
||||||
|
scheduleNextBackgroundPreload()
|
||||||
return
|
return
|
||||||
|
} finally {
|
||||||
|
if (opticalRevision > 0) cancelOpticalWallpaperTransaction(opticalRevision)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) {
|
if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) {
|
||||||
@@ -657,8 +745,9 @@ async function rotateBackgroundImage() {
|
|||||||
// 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。
|
// 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。
|
||||||
function stopBackgroundRotation() {
|
function stopBackgroundRotation() {
|
||||||
backgroundRotationVersion += 1
|
backgroundRotationVersion += 1
|
||||||
|
cancelNextBackgroundPreload()
|
||||||
removeBackgroundTimer('background-rotation')
|
removeBackgroundTimer('background-rotation')
|
||||||
settlePendingOpticalWallpaper(false)
|
cancelOpticalWallpaperTransaction()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBackgroundRotationGrace() {
|
function clearBackgroundRotationGrace() {
|
||||||
@@ -684,7 +773,7 @@ function startBackgroundRotation() {
|
|||||||
stopBackgroundRotation()
|
stopBackgroundRotation()
|
||||||
|
|
||||||
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
|
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
|
||||||
preloadNextBackgroundImage()
|
scheduleNextBackgroundPreload()
|
||||||
// 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。
|
// 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。
|
||||||
addBackgroundTimer(
|
addBackgroundTimer(
|
||||||
'background-rotation',
|
'background-rotation',
|
||||||
@@ -784,11 +873,15 @@ async function animateAndRemoveLoader() {
|
|||||||
document.documentElement.style.removeProperty('overflow')
|
document.documentElement.style.removeProperty('overflow')
|
||||||
document.body.style.removeProperty('overflow')
|
document.body.style.removeProperty('overflow')
|
||||||
completeLaunchLoading()
|
completeLaunchLoading()
|
||||||
|
recordGlassLaunchTiming('loader-removed')
|
||||||
|
scheduleNextBackgroundPreload()
|
||||||
resolve()
|
resolve()
|
||||||
}, LAUNCH_EXIT_DURATION_MS)
|
}, LAUNCH_EXIT_DURATION_MS)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
completeLaunchLoading()
|
completeLaunchLoading()
|
||||||
|
recordGlassLaunchTiming('loader-removed')
|
||||||
|
scheduleNextBackgroundPreload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,6 +953,7 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
|
|||||||
}
|
}
|
||||||
backgroundRecoveryAttemptedVersion = -1
|
backgroundRecoveryAttemptedVersion = -1
|
||||||
resetBackgroundCrossfade()
|
resetBackgroundCrossfade()
|
||||||
|
recordGlassLaunchTiming('wallpaper-committed', activeBackgroundImage.value)
|
||||||
startBackgroundRotation()
|
startBackgroundRotation()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (loadVersion !== backgroundLoadVersion) return
|
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 () => {
|
onMounted(async () => {
|
||||||
// 移除URL中的时间戳参数
|
// 移除URL中的时间戳参数
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
@@ -915,28 +1031,6 @@ onMounted(async () => {
|
|||||||
window.addEventListener('focus', handlePageShowThemeSync)
|
window.addEventListener('focus', handlePageShowThemeSync)
|
||||||
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
|
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(() => {
|
ensureRenderComplete(() => {
|
||||||
nextTick(removeLoadingWithStateCheck)
|
nextTick(removeLoadingWithStateCheck)
|
||||||
@@ -1031,6 +1125,11 @@ onUnmounted(() => {
|
|||||||
:wallpaper-url="activeOpticalBackgroundImage"
|
:wallpaper-url="activeOpticalBackgroundImage"
|
||||||
:previous-wallpaper-url="previousOpticalBackgroundImage"
|
:previous-wallpaper-url="previousOpticalBackgroundImage"
|
||||||
:pending-wallpaper-url="pendingOpticalBackgroundImage"
|
: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"
|
@wallpaper-prepared="handleOpticalWallpaperPrepared"
|
||||||
/>
|
/>
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ import {
|
|||||||
GLASS_OPTICAL_STRENGTH_MAX,
|
GLASS_OPTICAL_STRENGTH_MAX,
|
||||||
GLASS_OPTICAL_STRENGTH_MIN,
|
GLASS_OPTICAL_STRENGTH_MIN,
|
||||||
getAvailableGlassOpticalPresets,
|
getAvailableGlassOpticalPresets,
|
||||||
|
getGlassOpticalPresetKey,
|
||||||
getGlassOpticalPresetParameters,
|
getGlassOpticalPresetParameters,
|
||||||
|
getGlassOpticalPresetParametersWithOverrides,
|
||||||
normalizeGlassOpticalStrength,
|
normalizeGlassOpticalStrength,
|
||||||
|
type GlassOpticalParameters,
|
||||||
type GlassOpticalPreset,
|
type GlassOpticalPreset,
|
||||||
|
type GlassOpticalPresetOverrides,
|
||||||
} from '@/utils/glassOptics'
|
} from '@/utils/glassOptics'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -32,11 +37,13 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const display = useDisplay()
|
||||||
const { settings } = useThemeCustomizer()
|
const { settings } = useThemeCustomizer()
|
||||||
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||||
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
||||||
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
||||||
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
||||||
|
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
|
||||||
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
|
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
|
||||||
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
|
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
|
||||||
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
|
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
|
||||||
@@ -68,6 +75,7 @@ watch(
|
|||||||
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
||||||
draftFlowStrength.value = settings.value.glassFlowStrength
|
draftFlowStrength.value = settings.value.glassFlowStrength
|
||||||
draftPreset.value = settings.value.glassPreset
|
draftPreset.value = settings.value.glassPreset
|
||||||
|
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
|
||||||
draftQuality.value = settings.value.glassQuality
|
draftQuality.value = settings.value.glassQuality
|
||||||
draftReflectionStrength.value = settings.value.glassReflectionStrength
|
draftReflectionStrength.value = settings.value.glassReflectionStrength
|
||||||
draftTransmissionStrength.value = settings.value.glassTransmissionStrength
|
draftTransmissionStrength.value = settings.value.glassTransmissionStrength
|
||||||
@@ -112,7 +120,7 @@ function updateAppearance(value: unknown) {
|
|||||||
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
|
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
|
||||||
|
|
||||||
draftAppearance.value = value
|
draftAppearance.value = value
|
||||||
previewGlassSettings({ glassAppearance: value })
|
applyPreset(activePreset.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅允许面板声明的质量档位进入待保存设置。 */
|
/** 仅允许面板声明的质量档位进入待保存设置。 */
|
||||||
@@ -121,15 +129,18 @@ function updateQuality(value: unknown) {
|
|||||||
if (!option) return
|
if (!option) return
|
||||||
|
|
||||||
draftQuality.value = option.value
|
draftQuality.value = option.value
|
||||||
previewGlassSettings({ glassQuality: option.value })
|
applyPreset(activePreset.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将六个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
|
/** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */
|
||||||
function previewDraftParameters() {
|
function previewDraftParameters() {
|
||||||
previewGlassSettings({
|
previewGlassSettings({
|
||||||
|
glassAppearance: draftAppearance.value,
|
||||||
glassDeformationStrength: draftDeformationStrength.value,
|
glassDeformationStrength: draftDeformationStrength.value,
|
||||||
glassFlowStrength: draftFlowStrength.value,
|
glassFlowStrength: draftFlowStrength.value,
|
||||||
glassPreset: draftPreset.value,
|
glassPreset: draftPreset.value,
|
||||||
|
glassPresetOverrides: draftPresetOverrides.value,
|
||||||
|
glassQuality: draftQuality.value,
|
||||||
glassReflectionStrength: draftReflectionStrength.value,
|
glassReflectionStrength: draftReflectionStrength.value,
|
||||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||||
glassTranslationStrength: draftTranslationStrength.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) {
|
function applyPreset(value: unknown) {
|
||||||
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
|
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
|
||||||
if (!availablePresets.value.includes(value)) return
|
const effectivePreset = availablePresets.value.includes(value) ? value : 'natural'
|
||||||
|
const parameters = getGlassOpticalPresetParametersWithOverrides(
|
||||||
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value)
|
draftAppearance.value,
|
||||||
draftPreset.value = value
|
draftQuality.value,
|
||||||
|
effectivePreset,
|
||||||
|
draftPresetOverrides.value,
|
||||||
|
)
|
||||||
|
draftPreset.value = effectivePreset
|
||||||
draftDeformationStrength.value = parameters.deformation
|
draftDeformationStrength.value = parameters.deformation
|
||||||
draftFlowStrength.value = parameters.flow
|
draftFlowStrength.value = parameters.flow
|
||||||
draftReflectionStrength.value = parameters.reflection
|
draftReflectionStrength.value = parameters.reflection
|
||||||
@@ -153,45 +180,66 @@ function applyPreset(value: unknown) {
|
|||||||
previewDraftParameters()
|
previewDraftParameters()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用调整后的当前六参数覆盖当前材质、质量与方案组合。 */
|
||||||
|
function updateDraftPresetOverride() {
|
||||||
|
const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
|
||||||
|
draftPresetOverrides.value = {
|
||||||
|
...draftPresetOverrides.value,
|
||||||
|
[key]: getDraftParameters(),
|
||||||
|
}
|
||||||
|
previewDraftParameters()
|
||||||
|
}
|
||||||
|
|
||||||
/** 将采样平移限制为 renderer 支持的稳定范围。 */
|
/** 将采样平移限制为 renderer 支持的稳定范围。 */
|
||||||
function updateTranslationStrength(value: unknown) {
|
function updateTranslationStrength(value: unknown) {
|
||||||
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||||
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value })
|
updateDraftPresetOverride()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
|
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
|
||||||
function updateDeformationStrength(value: unknown) {
|
function updateDeformationStrength(value: unknown) {
|
||||||
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||||
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value })
|
updateDraftPresetOverride()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
|
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
|
||||||
function updateFlowStrength(value: unknown) {
|
function updateFlowStrength(value: unknown) {
|
||||||
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||||
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value })
|
updateDraftPresetOverride()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
|
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
|
||||||
function updateReflectionStrength(value: unknown) {
|
function updateReflectionStrength(value: unknown) {
|
||||||
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||||
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
|
updateDraftPresetOverride()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
|
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
|
||||||
function updateTransmissionStrength(value: unknown) {
|
function updateTransmissionStrength(value: unknown) {
|
||||||
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||||
previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value })
|
updateDraftPresetOverride()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
|
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
|
||||||
function updateTransparencyStrength(value: unknown) {
|
function updateTransparencyStrength(value: unknown) {
|
||||||
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||||
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value })
|
updateDraftPresetOverride()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */
|
/** 删除当前组合覆盖并恢复该方案矩阵,不影响其他组合。 */
|
||||||
function resetSettings() {
|
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,
|
glassDeformationStrength: draftDeformationStrength.value,
|
||||||
glassFlowStrength: draftFlowStrength.value,
|
glassFlowStrength: draftFlowStrength.value,
|
||||||
glassPreset: draftPreset.value,
|
glassPreset: draftPreset.value,
|
||||||
|
glassPresetOverrides: draftPresetOverrides.value,
|
||||||
glassQuality: draftQuality.value,
|
glassQuality: draftQuality.value,
|
||||||
glassReflectionStrength: draftReflectionStrength.value,
|
glassReflectionStrength: draftReflectionStrength.value,
|
||||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||||
@@ -224,7 +273,14 @@ onScopeDispose(cancelGlassPreview)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<VCard>
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VCardTitle>
|
<VCardTitle>
|
||||||
@@ -415,14 +471,21 @@ onScopeDispose(cancelGlassPreview)
|
|||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText class="text-center">
|
<VCardActions class="glass-settings-dialog__actions justify-center">
|
||||||
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
|
<VBtn :slim="false" variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
|
||||||
{{ t('common.reset') }}
|
{{ t('common.reset') }}
|
||||||
</VBtn>
|
</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') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardText>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -436,6 +499,11 @@ onScopeDispose(cancelGlassPreview)
|
|||||||
padding: 20px 24px 24px;
|
padding: 20px 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__actions {
|
||||||
|
flex: none;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.glass-settings-dialog__label {
|
.glass-settings-dialog__label {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
|||||||
@@ -173,12 +173,7 @@ onBeforeMount(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
|
||||||
<!-- Vuetify 渲染模式 -->
|
<!-- Vuetify 渲染模式 -->
|
||||||
<VCard
|
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
|
||||||
v-if="renderMode === 'vuetify'"
|
|
||||||
:title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"
|
|
||||||
data-glass-optical-surface
|
|
||||||
data-glass-optical-mode="static-material"
|
|
||||||
>
|
|
||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||||
@@ -213,7 +208,7 @@ onBeforeMount(async () => {
|
|||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
<!-- Vue 渲染模式 -->
|
<!-- 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">
|
<VCardText class="pa-0">
|
||||||
<component
|
<component
|
||||||
:is="dynamicComponent"
|
:is="dynamicComponent"
|
||||||
|
|||||||
@@ -136,12 +136,7 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<!-- Vuetify 渲染模式 -->
|
<!-- Vuetify 渲染模式 -->
|
||||||
<VCard
|
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
|
||||||
v-if="renderMode === 'vuetify'"
|
|
||||||
:title="`${props.plugin?.plugin_name}`"
|
|
||||||
data-glass-optical-surface
|
|
||||||
data-glass-optical-mode="static-material"
|
|
||||||
>
|
|
||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||||
<VCardText v-else class="min-h-40">
|
<VCardText v-else class="min-h-40">
|
||||||
@@ -163,7 +158,7 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</VCard>
|
</VCard>
|
||||||
<!-- Vue 渲染模式 -->
|
<!-- 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">
|
<VCardText class="pa-0">
|
||||||
<component
|
<component
|
||||||
:is="dynamicComponent"
|
:is="dynamicComponent"
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
|
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
|
||||||
|
|
||||||
const slotStub = { template: '<div><slot /></div>' }
|
const slotStub = { template: '<div><slot /></div>' }
|
||||||
|
const dialogStub = {
|
||||||
|
props: ['fullscreen'],
|
||||||
|
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
|
||||||
|
}
|
||||||
const toggleStub = {
|
const toggleStub = {
|
||||||
props: ['modelValue'],
|
props: ['modelValue'],
|
||||||
template: '<div :data-model-value="modelValue"><slot /></div>',
|
template: '<div :data-model-value="modelValue"><slot /></div>',
|
||||||
@@ -25,6 +29,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
glassDeformationStrength: 50,
|
glassDeformationStrength: 50,
|
||||||
glassFlowStrength: 50,
|
glassFlowStrength: 50,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
|
glassPresetOverrides: {},
|
||||||
glassQuality: 'css',
|
glassQuality: 'css',
|
||||||
glassReflectionStrength: 50,
|
glassReflectionStrength: 50,
|
||||||
glassTransmissionStrength: 50,
|
glassTransmissionStrength: 50,
|
||||||
@@ -47,6 +52,10 @@ vi.mock('vue-i18n', () => ({
|
|||||||
useI18n: () => ({ t: (key: string) => key }),
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('vuetify', () => ({
|
||||||
|
useDisplay: () => ({ smAndDown: { value: true } }),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('GlassSettingsDialog', () => {
|
describe('GlassSettingsDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.cancelGlassPreview.mockClear()
|
mocks.cancelGlassPreview.mockClear()
|
||||||
@@ -56,6 +65,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
mocks.settings.value.glassDeformationStrength = 50
|
mocks.settings.value.glassDeformationStrength = 50
|
||||||
mocks.settings.value.glassFlowStrength = 50
|
mocks.settings.value.glassFlowStrength = 50
|
||||||
mocks.settings.value.glassPreset = 'natural'
|
mocks.settings.value.glassPreset = 'natural'
|
||||||
|
mocks.settings.value.glassPresetOverrides = {}
|
||||||
mocks.settings.value.glassQuality = 'css'
|
mocks.settings.value.glassQuality = 'css'
|
||||||
mocks.settings.value.glassReflectionStrength = 50
|
mocks.settings.value.glassReflectionStrength = 50
|
||||||
mocks.settings.value.glassTransmissionStrength = 50
|
mocks.settings.value.glassTransmissionStrength = 50
|
||||||
@@ -68,8 +78,9 @@ describe('GlassSettingsDialog', () => {
|
|||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
VCard: slotStub,
|
VCard: slotStub,
|
||||||
|
VCardActions: slotStub,
|
||||||
VCardText: slotStub,
|
VCardText: slotStub,
|
||||||
VDialog: slotStub,
|
VDialog: dialogStub,
|
||||||
VDialogCloseBtn: true,
|
VDialogCloseBtn: true,
|
||||||
VSlider: sliderStub,
|
VSlider: sliderStub,
|
||||||
},
|
},
|
||||||
@@ -81,6 +92,8 @@ describe('GlassSettingsDialog', () => {
|
|||||||
expect(sliders).toHaveLength(3)
|
expect(sliders).toHaveLength(3)
|
||||||
expect(sliders[0].attributes('data-disabled')).toBe('undefined')
|
expect(sliders[0].attributes('data-disabled')).toBe('undefined')
|
||||||
expect(sliders[1].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 })
|
await wrapper.setProps({ modelValue: false })
|
||||||
|
|
||||||
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
|
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
|
||||||
@@ -96,17 +109,37 @@ describe('GlassSettingsDialog', () => {
|
|||||||
mocks.settings.value.glassTransmissionStrength = 57
|
mocks.settings.value.glassTransmissionStrength = 57
|
||||||
mocks.settings.value.glassTranslationStrength = 57
|
mocks.settings.value.glassTranslationStrength = 57
|
||||||
mocks.settings.value.glassTransparencyStrength = 55
|
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, {
|
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
VCard: slotStub,
|
VCard: slotStub,
|
||||||
|
VCardActions: slotStub,
|
||||||
VCardText: slotStub,
|
VCardText: slotStub,
|
||||||
VBtn: {
|
VBtn: {
|
||||||
emits: ['click'],
|
emits: ['click'],
|
||||||
props: ['prependIcon'],
|
props: ['prependIcon', 'slim', 'variant'],
|
||||||
template: '<button :data-icon="prependIcon" @click="$emit(\'click\')"><slot /></button>',
|
template:
|
||||||
|
'<button :data-icon="prependIcon" :data-slim="String(slim)" :data-variant="variant" @click="$emit(\'click\')"><slot /></button>',
|
||||||
},
|
},
|
||||||
VDialog: slotStub,
|
VDialog: dialogStub,
|
||||||
VDialogCloseBtn: true,
|
VDialogCloseBtn: true,
|
||||||
VDivider: true,
|
VDivider: true,
|
||||||
VSlider: sliderStub,
|
VSlider: sliderStub,
|
||||||
@@ -115,17 +148,33 @@ describe('GlassSettingsDialog', () => {
|
|||||||
props: { modelValue: true },
|
props: { modelValue: true },
|
||||||
})
|
})
|
||||||
const resetButton = wrapper.find('[data-icon="mdi-refresh"]')
|
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')
|
await resetButton.trigger('click')
|
||||||
|
|
||||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||||
glassDeformationStrength: 82,
|
glassAppearance: 'frosted',
|
||||||
glassFlowStrength: 80,
|
glassDeformationStrength: 66,
|
||||||
|
glassFlowStrength: 64,
|
||||||
glassPreset: 'liquid',
|
glassPreset: 'liquid',
|
||||||
|
glassPresetOverrides: {
|
||||||
|
'clear:balanced:glide': {
|
||||||
|
deformation: 24,
|
||||||
|
flow: 35,
|
||||||
|
reflection: 46,
|
||||||
|
transmission: 57,
|
||||||
|
translation: 68,
|
||||||
|
transparency: 79,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
glassQuality: 'high',
|
||||||
glassReflectionStrength: 31,
|
glassReflectionStrength: 31,
|
||||||
glassTransmissionStrength: 58,
|
glassTransmissionStrength: 47,
|
||||||
glassTranslationStrength: 54,
|
glassTranslationStrength: 43,
|
||||||
glassTransparencyStrength: 46,
|
glassTransparencyStrength: 32,
|
||||||
})
|
})
|
||||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -135,8 +184,9 @@ describe('GlassSettingsDialog', () => {
|
|||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
VCard: slotStub,
|
VCard: slotStub,
|
||||||
|
VCardActions: slotStub,
|
||||||
VCardText: slotStub,
|
VCardText: slotStub,
|
||||||
VDialog: slotStub,
|
VDialog: dialogStub,
|
||||||
VDialogCloseBtn: true,
|
VDialogCloseBtn: true,
|
||||||
VSlider: sliderStub,
|
VSlider: sliderStub,
|
||||||
},
|
},
|
||||||
@@ -149,7 +199,27 @@ describe('GlassSettingsDialog', () => {
|
|||||||
expect(sliders[2].attributes('data-disabled')).toBe('undefined')
|
expect(sliders[2].attributes('data-disabled')).toBe('undefined')
|
||||||
await sliders[2].setValue('86')
|
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', () => {
|
it('hides preset choices in standard quality', () => {
|
||||||
@@ -157,8 +227,9 @@ describe('GlassSettingsDialog', () => {
|
|||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
VCard: slotStub,
|
VCard: slotStub,
|
||||||
|
VCardActions: slotStub,
|
||||||
VCardText: slotStub,
|
VCardText: slotStub,
|
||||||
VDialog: slotStub,
|
VDialog: dialogStub,
|
||||||
VDialogCloseBtn: true,
|
VDialogCloseBtn: true,
|
||||||
VSlider: sliderStub,
|
VSlider: sliderStub,
|
||||||
},
|
},
|
||||||
@@ -170,7 +241,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
|
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.glassQuality = 'balanced'
|
||||||
mocks.settings.value.glassPreset = 'glide'
|
mocks.settings.value.glassPreset = 'glide'
|
||||||
mocks.settings.value.glassTransparencyStrength = 61
|
mocks.settings.value.glassTransparencyStrength = 61
|
||||||
@@ -178,10 +249,11 @@ describe('GlassSettingsDialog', () => {
|
|||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
VCard: slotStub,
|
VCard: slotStub,
|
||||||
|
VCardActions: slotStub,
|
||||||
VCardText: slotStub,
|
VCardText: slotStub,
|
||||||
VBtn: slotStub,
|
VBtn: slotStub,
|
||||||
VBtnToggle: toggleStub,
|
VBtnToggle: toggleStub,
|
||||||
VDialog: slotStub,
|
VDialog: dialogStub,
|
||||||
VDialogCloseBtn: true,
|
VDialogCloseBtn: true,
|
||||||
VSlider: sliderStub,
|
VSlider: sliderStub,
|
||||||
},
|
},
|
||||||
@@ -199,6 +271,21 @@ describe('GlassSettingsDialog', () => {
|
|||||||
await sliders[0].setValue('77')
|
await sliders[0].setValue('77')
|
||||||
|
|
||||||
expect(preset.attributes('data-model-value')).toBe('glide')
|
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 () => {
|
it('normalizes and previews all six independent slider values', async () => {
|
||||||
@@ -207,8 +294,9 @@ describe('GlassSettingsDialog', () => {
|
|||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
VCard: slotStub,
|
VCard: slotStub,
|
||||||
|
VCardActions: slotStub,
|
||||||
VCardText: slotStub,
|
VCardText: slotStub,
|
||||||
VDialog: slotStub,
|
VDialog: dialogStub,
|
||||||
VDialogCloseBtn: true,
|
VDialogCloseBtn: true,
|
||||||
VSlider: sliderStub,
|
VSlider: sliderStub,
|
||||||
},
|
},
|
||||||
@@ -227,12 +315,28 @@ describe('GlassSettingsDialog', () => {
|
|||||||
await sliders[4].setValue('68.7')
|
await sliders[4].setValue('68.7')
|
||||||
await sliders[5].setValue('62.2')
|
await sliders[5].setValue('62.2')
|
||||||
|
|
||||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 })
|
expect(mocks.previewGlassSettings).toHaveBeenCalledTimes(6)
|
||||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassTransmissionStrength: 78 })
|
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
|
||||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassReflectionStrength: 74 })
|
glassAppearance: 'clear',
|
||||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassTranslationStrength: 84 })
|
glassDeformationStrength: 69,
|
||||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassDeformationStrength: 69 })
|
glassFlowStrength: 62,
|
||||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(6, { 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()
|
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -242,8 +346,9 @@ describe('GlassSettingsDialog', () => {
|
|||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
VCard: slotStub,
|
VCard: slotStub,
|
||||||
|
VCardActions: slotStub,
|
||||||
VCardText: slotStub,
|
VCardText: slotStub,
|
||||||
VDialog: slotStub,
|
VDialog: dialogStub,
|
||||||
VDialogCloseBtn: true,
|
VDialogCloseBtn: true,
|
||||||
VSlider: sliderStub,
|
VSlider: sliderStub,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
||||||
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||||
import {
|
import {
|
||||||
|
createGlassWallpaperSourceCache,
|
||||||
|
getGlassWallpaperPreparationKey,
|
||||||
setGlassRendererState,
|
setGlassRendererState,
|
||||||
useGlassOpticalInteractionSource,
|
useGlassOpticalInteractionSource,
|
||||||
useGlassOpticalRenderer,
|
useGlassOpticalRenderer,
|
||||||
@@ -39,16 +41,40 @@ const props = defineProps<{
|
|||||||
previousWallpaperUrl: string
|
previousWallpaperUrl: string
|
||||||
/** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */
|
/** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */
|
||||||
pendingWallpaperUrl?: string
|
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<{
|
const emit = defineEmits<{
|
||||||
/** fixed 与 scroll renderer 均已准备同一张待切换纹理。 */
|
/** 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 fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const interactionSource = useGlassOpticalInteractionSource()
|
const interactionSource = useGlassOpticalInteractionSource()
|
||||||
const pagePresentationMotion = usePagePresentationMotion()
|
const pagePresentationMotion = usePagePresentationMotion()
|
||||||
|
const wallpaperSourceCache = createGlassWallpaperSourceCache()
|
||||||
const fixedRenderer = useGlassOpticalRenderer({
|
const fixedRenderer = useGlassOpticalRenderer({
|
||||||
active: true,
|
active: true,
|
||||||
appearance: () => props.appearance,
|
appearance: () => props.appearance,
|
||||||
@@ -66,8 +92,10 @@ const fixedRenderer = useGlassOpticalRenderer({
|
|||||||
transitionDuration: () => props.transitionDuration,
|
transitionDuration: () => props.transitionDuration,
|
||||||
transitionStartedAt: () => props.transitionStartedAt,
|
transitionStartedAt: () => props.transitionStartedAt,
|
||||||
wallpaperUrl: () => props.wallpaperUrl,
|
wallpaperUrl: () => props.wallpaperUrl,
|
||||||
|
wallpaperSourceCache,
|
||||||
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
||||||
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
|
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
|
||||||
|
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
|
||||||
surfaceSpace: 'fixed',
|
surfaceSpace: 'fixed',
|
||||||
syncDocumentState: false,
|
syncDocumentState: false,
|
||||||
})
|
})
|
||||||
@@ -89,8 +117,10 @@ const scrollRenderer = useGlassOpticalRenderer({
|
|||||||
transitionDuration: () => props.transitionDuration,
|
transitionDuration: () => props.transitionDuration,
|
||||||
transitionStartedAt: () => props.transitionStartedAt,
|
transitionStartedAt: () => props.transitionStartedAt,
|
||||||
wallpaperUrl: () => props.wallpaperUrl,
|
wallpaperUrl: () => props.wallpaperUrl,
|
||||||
|
wallpaperSourceCache,
|
||||||
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
||||||
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
|
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
|
||||||
|
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
|
||||||
surfaceSpace: 'scroll',
|
surfaceSpace: 'scroll',
|
||||||
syncDocumentState: false,
|
syncDocumentState: false,
|
||||||
})
|
})
|
||||||
@@ -107,16 +137,151 @@ watchEffect(() => {
|
|||||||
: 'fallback'
|
: 'fallback'
|
||||||
|
|
||||||
setGlassRendererState(rendererState, state)
|
setGlassRendererState(rendererState, state)
|
||||||
})
|
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
|
||||||
|
timingWindow.__glassLaunchTimings?.push({
|
||||||
watchEffect(() => {
|
detail: state,
|
||||||
const url = props.pendingWallpaperUrl
|
stage: 'optical-layer-state',
|
||||||
if (url && fixedRenderer.preparedWallpaperUrl.value === url && scrollRenderer.preparedWallpaperUrl.value === url) {
|
time: performance.now(),
|
||||||
emit('wallpaperPrepared', url)
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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(() => {
|
onScopeDispose(() => {
|
||||||
|
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
|
||||||
setGlassRendererState(rendererState, 'fallback')
|
setGlassRendererState(rendererState, 'fallback')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { shallowMount } from '@vue/test-utils'
|
import { shallowMount } from '@vue/test-utils'
|
||||||
import { ref } from 'vue'
|
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'
|
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
|
||||||
|
|
||||||
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
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 interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() }))
|
||||||
|
const wallpaperSourceCache = vi.hoisted(() => ({ get: vi.fn() }))
|
||||||
const setRendererState = vi.hoisted(() =>
|
const setRendererState = vi.hoisted(() =>
|
||||||
vi.fn((state: { value: string }, value: string) => {
|
vi.fn((state: { value: string }, value: string) => {
|
||||||
state.value = value
|
state.value = value
|
||||||
@@ -12,21 +32,82 @@ const setRendererState = vi.hoisted(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
vi.mock('@/composables/useGlassOpticalRenderer', () => ({
|
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,
|
setGlassRendererState: setRendererState,
|
||||||
useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
|
useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
|
||||||
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
|
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
|
||||||
rendererCalls.push(options)
|
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),
|
renderedFrames: ref(0),
|
||||||
state: ref('ready'),
|
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', () => {
|
describe('GlassOpticalLayer', () => {
|
||||||
it('uses exactly two visible presentation contexts with one interaction source', () => {
|
it('uses exactly two visible presentation contexts with one interaction source', () => {
|
||||||
rendererCalls.length = 0
|
rendererCalls.length = 0
|
||||||
|
rendererResults.length = 0
|
||||||
setRendererState.mockClear()
|
setRendererState.mockClear()
|
||||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||||
props: {
|
props: {
|
||||||
@@ -52,6 +133,7 @@ describe('GlassOpticalLayer', () => {
|
|||||||
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
|
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
|
||||||
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
||||||
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
||||||
|
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
|
||||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
||||||
expect(rendererCalls[0].pageMotion).toBeUndefined()
|
expect(rendererCalls[0].pageMotion).toBeUndefined()
|
||||||
expect(rendererCalls[1].pageMotion).toEqual(
|
expect(rendererCalls[1].pageMotion).toEqual(
|
||||||
@@ -64,4 +146,241 @@ describe('GlassOpticalLayer', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
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
@@ -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()
|
callbacks = new Map()
|
||||||
frameId = 0
|
frameId = 0
|
||||||
document.documentElement.dataset.theme = 'glass'
|
document.documentElement.dataset.theme = 'glass'
|
||||||
|
delete document.documentElement.dataset.launchLoading
|
||||||
vi.spyOn(performance, 'now').mockReturnValue(1000)
|
vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||||
frameId += 1
|
frameId += 1
|
||||||
@@ -30,13 +31,40 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
motion.cancel()
|
motion.cancel()
|
||||||
|
document.getElementById('loading-bg')?.remove()
|
||||||
delete document.documentElement.dataset.theme
|
delete document.documentElement.dataset.theme
|
||||||
|
delete document.documentElement.dataset.launchLoading
|
||||||
delete document.documentElement.dataset.pagePresentationMotion
|
delete document.documentElement.dataset.pagePresentationMotion
|
||||||
document.documentElement.style.removeProperty('--mp-page-motion-opacity')
|
document.documentElement.style.removeProperty('--mp-page-motion-opacity')
|
||||||
document.documentElement.style.removeProperty('--mp-page-motion-translate-y')
|
document.documentElement.style.removeProperty('--mp-page-motion-translate-y')
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('page presentation motion', () => {
|
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', () => {
|
it('holds a glass route until its shared layout geometry remains stable', () => {
|
||||||
const routeRoot = document.createElement('div')
|
const routeRoot = document.createElement('div')
|
||||||
let routeHeight = 2096
|
let routeHeight = 2096
|
||||||
|
|||||||
@@ -6,11 +6,33 @@ import {
|
|||||||
previewGlassSettings,
|
previewGlassSettings,
|
||||||
readThemeCustomizerSettings,
|
readThemeCustomizerSettings,
|
||||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||||
|
useThemeCustomizer,
|
||||||
useEffectiveGlassSettings,
|
useEffectiveGlassSettings,
|
||||||
} from '@/composables/useThemeCustomizer'
|
} 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'
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
describe('useThemeCustomizer glass settings', () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
cancelGlassPreview()
|
cancelGlassPreview()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
@@ -22,14 +44,15 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
const settings = readThemeCustomizerSettings()
|
const settings = readThemeCustomizerSettings()
|
||||||
|
|
||||||
expect(settings.glassAppearance).toBe('clear')
|
expect(settings.glassAppearance).toBe('clear')
|
||||||
expect(settings.glassDeformationStrength).toBe(50)
|
expect(settings.glassDeformationStrength).toBe(40)
|
||||||
expect(settings.glassFlowStrength).toBe(50)
|
expect(settings.glassFlowStrength).toBe(40)
|
||||||
expect(settings.glassPreset).toBe('natural')
|
expect(settings.glassPreset).toBe('natural')
|
||||||
|
expect(settings.glassPresetOverrides).toEqual({})
|
||||||
expect(settings.glassQuality).toBe('balanced')
|
expect(settings.glassQuality).toBe('balanced')
|
||||||
expect(settings.glassReflectionStrength).toBe(35)
|
expect(settings.glassReflectionStrength).toBe(35)
|
||||||
expect(settings.glassTransmissionStrength).toBe(70)
|
expect(settings.glassTransmissionStrength).toBe(54)
|
||||||
expect(settings.glassTranslationStrength).toBe(50)
|
expect(settings.glassTranslationStrength).toBe(40)
|
||||||
expect(settings.glassTransparencyStrength).toBe(70)
|
expect(settings.glassTransparencyStrength).toBe(46)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
|
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.glassAppearance).toBe('clear')
|
||||||
expect(settings.glassPreset).toBe('natural')
|
expect(settings.glassPreset).toBe('natural')
|
||||||
|
expect(settings.glassPresetOverrides).toHaveProperty('clear:balanced:natural')
|
||||||
expect(settings.glassQuality).toBe('balanced')
|
expect(settings.glassQuality).toBe('balanced')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,6 +118,19 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance: 'clear' }))
|
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance: 'clear' }))
|
||||||
|
|
||||||
expect(readThemeCustomizerSettings().glassTransmissionStrength).toBe(50)
|
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', () => {
|
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.body.dataset.glassQuality).toBe('high')
|
||||||
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.35')
|
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.35')
|
||||||
expect(document.body.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(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
|
||||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
|
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(
|
||||||
expect(Number(document.body.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
|
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', () => {
|
it('previews glass settings without persisting them', () => {
|
||||||
@@ -134,7 +175,17 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
glassAppearance: 'tinted',
|
glassAppearance: 'tinted',
|
||||||
glassDeformationStrength: 74,
|
glassDeformationStrength: 74,
|
||||||
glassFlowStrength: 63,
|
glassFlowStrength: 63,
|
||||||
glassPreset: 'glide',
|
glassPreset: 'natural',
|
||||||
|
glassPresetOverrides: {
|
||||||
|
'tinted:css:natural': {
|
||||||
|
deformation: 74,
|
||||||
|
flow: 63,
|
||||||
|
reflection: 81,
|
||||||
|
transmission: 76,
|
||||||
|
translation: 69,
|
||||||
|
transparency: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
glassQuality: 'css',
|
glassQuality: 'css',
|
||||||
glassReflectionStrength: 81,
|
glassReflectionStrength: 81,
|
||||||
glassTransmissionStrength: 76,
|
glassTransmissionStrength: 76,
|
||||||
@@ -148,7 +199,17 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
glassAppearance: 'tinted',
|
glassAppearance: 'tinted',
|
||||||
glassDeformationStrength: 74,
|
glassDeformationStrength: 74,
|
||||||
glassFlowStrength: 63,
|
glassFlowStrength: 63,
|
||||||
glassPreset: 'glide',
|
glassPreset: 'natural',
|
||||||
|
glassPresetOverrides: {
|
||||||
|
'tinted:css:natural': {
|
||||||
|
deformation: 74,
|
||||||
|
flow: 63,
|
||||||
|
reflection: 81,
|
||||||
|
transmission: 76,
|
||||||
|
translation: 69,
|
||||||
|
transparency: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
glassQuality: 'css',
|
glassQuality: 'css',
|
||||||
glassReflectionStrength: 81,
|
glassReflectionStrength: 81,
|
||||||
glassTransmissionStrength: 76,
|
glassTransmissionStrength: 76,
|
||||||
@@ -175,6 +236,16 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
glassAppearance: 'tinted',
|
glassAppearance: 'tinted',
|
||||||
glassDeformationStrength: 42,
|
glassDeformationStrength: 42,
|
||||||
glassFlowStrength: 44,
|
glassFlowStrength: 44,
|
||||||
|
glassPresetOverrides: {
|
||||||
|
'tinted:balanced:natural': {
|
||||||
|
deformation: 42,
|
||||||
|
flow: 44,
|
||||||
|
reflection: 66,
|
||||||
|
transmission: 64,
|
||||||
|
translation: 46,
|
||||||
|
transparency: 72,
|
||||||
|
},
|
||||||
|
},
|
||||||
glassReflectionStrength: 66,
|
glassReflectionStrength: 66,
|
||||||
glassTransmissionStrength: 64,
|
glassTransmissionStrength: 64,
|
||||||
glassTranslationStrength: 46,
|
glassTranslationStrength: 46,
|
||||||
@@ -184,6 +255,16 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
glassAppearance: 'clear',
|
glassAppearance: 'clear',
|
||||||
glassDeformationStrength: 90,
|
glassDeformationStrength: 90,
|
||||||
glassFlowStrength: 88,
|
glassFlowStrength: 88,
|
||||||
|
glassPresetOverrides: {
|
||||||
|
'clear:balanced:natural': {
|
||||||
|
deformation: 90,
|
||||||
|
flow: 88,
|
||||||
|
reflection: 12,
|
||||||
|
transmission: 92,
|
||||||
|
translation: 86,
|
||||||
|
transparency: 94,
|
||||||
|
},
|
||||||
|
},
|
||||||
glassReflectionStrength: 12,
|
glassReflectionStrength: 12,
|
||||||
glassTransmissionStrength: 92,
|
glassTransmissionStrength: 92,
|
||||||
glassTranslationStrength: 86,
|
glassTranslationStrength: 86,
|
||||||
@@ -206,10 +287,146 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
glassAppearance: 'tinted',
|
glassAppearance: 'tinted',
|
||||||
glassDeformationStrength: 42,
|
glassDeformationStrength: 42,
|
||||||
glassFlowStrength: 44,
|
glassFlowStrength: 44,
|
||||||
|
glassPresetOverrides: {
|
||||||
|
'tinted:balanced:natural': {
|
||||||
|
deformation: 42,
|
||||||
|
flow: 44,
|
||||||
|
reflection: 66,
|
||||||
|
transmission: 64,
|
||||||
|
translation: 46,
|
||||||
|
transparency: 72,
|
||||||
|
},
|
||||||
|
},
|
||||||
glassReflectionStrength: 66,
|
glassReflectionStrength: 66,
|
||||||
glassTransmissionStrength: 64,
|
glassTransmissionStrength: 64,
|
||||||
glassTranslationStrength: 46,
|
glassTranslationStrength: 46,
|
||||||
glassTransparencyStrength: 72,
|
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
@@ -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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
animationFrame = window.requestAnimationFrame(nextTimestamp =>
|
animationFrame = window.requestAnimationFrame(nextTimestamp => sampleLayoutHold(nextTimestamp, motionEpoch, root))
|
||||||
sampleLayoutHold(nextTimestamp, motionEpoch, root),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function settleMotion() {
|
function settleMotion() {
|
||||||
@@ -178,6 +176,13 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
|
|||||||
const motionEpoch = epoch.value
|
const motionEpoch = epoch.value
|
||||||
routeKey.value = nextRouteKey
|
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) {
|
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||||
settleMotion()
|
settleMotion()
|
||||||
revision.value += 1
|
revision.value += 1
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import vuetify from '@/plugins/vuetify'
|
|||||||
import {
|
import {
|
||||||
GLASS_OPTICAL_STRENGTH_DEFAULT,
|
GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||||
GLASS_OPTICAL_STRENGTH_MAX,
|
GLASS_OPTICAL_STRENGTH_MAX,
|
||||||
|
getGlassCssFrostBlur,
|
||||||
|
getGlassMaterialResponse,
|
||||||
getGlassOpticalCssTransmissionBrightness,
|
getGlassOpticalCssTransmissionBrightness,
|
||||||
|
getGlassOpticalPresetKey,
|
||||||
getGlassOpticalPresetParameters,
|
getGlassOpticalPresetParameters,
|
||||||
|
getGlassOpticalPresetParametersWithOverrides,
|
||||||
getGlassOpticalTransmissionStrength,
|
getGlassOpticalTransmissionStrength,
|
||||||
getGlassOpticalTransparency,
|
|
||||||
normalizeGlassOpticalStrength,
|
normalizeGlassOpticalStrength,
|
||||||
|
type GlassOpticalParameters,
|
||||||
type GlassOpticalPreset,
|
type GlassOpticalPreset,
|
||||||
|
type GlassOpticalPresetOverrides,
|
||||||
} from '@/utils/glassOptics'
|
} from '@/utils/glassOptics'
|
||||||
import { themeManager } from '@/utils/themeManager'
|
import { themeManager } from '@/utils/themeManager'
|
||||||
import { syncThemeFavicon } from '@/utils/themePalette'
|
import { syncThemeFavicon } from '@/utils/themePalette'
|
||||||
@@ -80,6 +85,8 @@ export interface ThemeCustomizerSettings {
|
|||||||
glassFlowStrength: number
|
glassFlowStrength: number
|
||||||
/** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */
|
/** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */
|
||||||
glassPreset: GlassOpticalPreset
|
glassPreset: GlassOpticalPreset
|
||||||
|
/** 按材质、质量与方案保存的六参数覆盖;缺失组合使用预设矩阵。 */
|
||||||
|
glassPresetOverrides: GlassOpticalPresetOverrides
|
||||||
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
|
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
|
||||||
glassQuality: ThemeCustomizerGlassQuality
|
glassQuality: ThemeCustomizerGlassQuality
|
||||||
/** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */
|
/** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */
|
||||||
@@ -155,6 +162,7 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
|||||||
glassDeformationStrength: glassParameters.deformation,
|
glassDeformationStrength: glassParameters.deformation,
|
||||||
glassFlowStrength: glassParameters.flow,
|
glassFlowStrength: glassParameters.flow,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
|
glassPresetOverrides: {},
|
||||||
glassQuality: defaultGlassQuality,
|
glassQuality: defaultGlassQuality,
|
||||||
glassReflectionStrength: glassParameters.reflection,
|
glassReflectionStrength: glassParameters.reflection,
|
||||||
glassTransmissionStrength: glassParameters.transmission,
|
glassTransmissionStrength: glassParameters.transmission,
|
||||||
@@ -183,6 +191,49 @@ function normalizeMigratedGlassStrength(value: unknown, legacyValue: unknown, fa
|
|||||||
return fallback
|
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 数值档位。 */
|
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
|
||||||
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
|
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
|
||||||
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as 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 storedRadius = settings.radius as string | undefined
|
||||||
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||||
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
|
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)
|
glassAppearance: validGlassAppearances.includes(settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||||
? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||||
: fallback.glassAppearance,
|
: fallback.glassAppearance,
|
||||||
@@ -215,9 +269,8 @@ function normalizeThemeCustomizerSettings(
|
|||||||
settings.glassMotionStrength,
|
settings.glassMotionStrength,
|
||||||
fallback.glassFlowStrength,
|
fallback.glassFlowStrength,
|
||||||
),
|
),
|
||||||
glassPreset: validGlassPresets.includes(settings.glassPreset as GlassOpticalPreset)
|
glassPreset: storedPreset,
|
||||||
? (settings.glassPreset as GlassOpticalPreset)
|
glassPresetOverrides: normalizeGlassPresetOverrides(settings.glassPresetOverrides),
|
||||||
: fallback.glassPreset,
|
|
||||||
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
|
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||||
? (settings.glassQuality as ThemeCustomizerGlassQuality)
|
? (settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||||
: fallback.glassQuality,
|
: fallback.glassQuality,
|
||||||
@@ -254,6 +307,19 @@ function normalizeThemeCustomizerSettings(
|
|||||||
? (settings.theme as ThemeCustomizerTheme)
|
? (settings.theme as ThemeCustomizerTheme)
|
||||||
: fallback.theme,
|
: 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'
|
| 'glassDeformationStrength'
|
||||||
| 'glassFlowStrength'
|
| 'glassFlowStrength'
|
||||||
| 'glassPreset'
|
| 'glassPreset'
|
||||||
|
| 'glassPresetOverrides'
|
||||||
| 'glassQuality'
|
| 'glassQuality'
|
||||||
| 'glassReflectionStrength'
|
| 'glassReflectionStrength'
|
||||||
| 'glassTransmissionStrength'
|
| 'glassTransmissionStrength'
|
||||||
@@ -294,6 +361,7 @@ const effectiveGlassSettings = computed(() => ({
|
|||||||
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
|
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
|
||||||
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
|
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
|
||||||
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
|
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
|
||||||
|
glassPresetOverrides: glassPreviewState.value?.glassPresetOverrides ?? settingsState.value.glassPresetOverrides,
|
||||||
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
|
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
|
||||||
glassReflectionStrength:
|
glassReflectionStrength:
|
||||||
glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength,
|
glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength,
|
||||||
@@ -375,6 +443,18 @@ export function applyThemeCustomizerRootSettings(
|
|||||||
) {
|
) {
|
||||||
if (!isBrowser()) return
|
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-appearance', settings.glassAppearance)
|
||||||
document.documentElement.setAttribute('data-glass-quality', settings.glassQuality)
|
document.documentElement.setAttribute('data-glass-quality', settings.glassQuality)
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty(
|
||||||
@@ -389,10 +469,7 @@ export function applyThemeCustomizerRootSettings(
|
|||||||
'--glass-transmission-brightness',
|
'--glass-transmission-brightness',
|
||||||
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
|
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
|
||||||
)
|
)
|
||||||
document.documentElement.style.setProperty(
|
applyGlassResponse(document.documentElement)
|
||||||
'--glass-transparency',
|
|
||||||
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
|
|
||||||
)
|
|
||||||
document.documentElement.setAttribute('data-theme-layout', settings.layout)
|
document.documentElement.setAttribute('data-theme-layout', settings.layout)
|
||||||
document.documentElement.setAttribute('data-theme-radius', settings.radius)
|
document.documentElement.setAttribute('data-theme-radius', settings.radius)
|
||||||
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||||
@@ -412,10 +489,7 @@ export function applyThemeCustomizerRootSettings(
|
|||||||
'--glass-transmission-brightness',
|
'--glass-transmission-brightness',
|
||||||
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
|
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
|
||||||
)
|
)
|
||||||
document.body.style.setProperty(
|
applyGlassResponse(document.body)
|
||||||
'--glass-transparency',
|
|
||||||
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
|
|
||||||
)
|
|
||||||
document.body.setAttribute('data-theme-layout', settings.layout)
|
document.body.setAttribute('data-theme-layout', settings.layout)
|
||||||
document.body.setAttribute('data-theme-radius', settings.radius)
|
document.body.setAttribute('data-theme-radius', settings.radius)
|
||||||
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||||
@@ -496,6 +570,7 @@ export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings
|
|||||||
glassDeformationStrength: previewSettings.glassDeformationStrength,
|
glassDeformationStrength: previewSettings.glassDeformationStrength,
|
||||||
glassFlowStrength: previewSettings.glassFlowStrength,
|
glassFlowStrength: previewSettings.glassFlowStrength,
|
||||||
glassPreset: previewSettings.glassPreset,
|
glassPreset: previewSettings.glassPreset,
|
||||||
|
glassPresetOverrides: previewSettings.glassPresetOverrides,
|
||||||
glassQuality: previewSettings.glassQuality,
|
glassQuality: previewSettings.glassQuality,
|
||||||
glassReflectionStrength: previewSettings.glassReflectionStrength,
|
glassReflectionStrength: previewSettings.glassReflectionStrength,
|
||||||
glassTransmissionStrength: previewSettings.glassTransmissionStrength,
|
glassTransmissionStrength: previewSettings.glassTransmissionStrength,
|
||||||
@@ -539,6 +614,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
|||||||
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||||
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
|
glassPresetOverrides: {},
|
||||||
glassQuality: defaultGlassQuality,
|
glassQuality: defaultGlassQuality,
|
||||||
glassReflectionStrength: glassParameters.reflection,
|
glassReflectionStrength: glassParameters.reflection,
|
||||||
glassTransmissionStrength: glassParameters.transmission,
|
glassTransmissionStrength: glassParameters.transmission,
|
||||||
@@ -558,6 +634,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
|||||||
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
|
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
|
||||||
settings.glassFlowStrength === defaults.glassFlowStrength &&
|
settings.glassFlowStrength === defaults.glassFlowStrength &&
|
||||||
settings.glassPreset === defaults.glassPreset &&
|
settings.glassPreset === defaults.glassPreset &&
|
||||||
|
JSON.stringify(settings.glassPresetOverrides) === JSON.stringify(defaults.glassPresetOverrides) &&
|
||||||
settings.glassQuality === defaults.glassQuality &&
|
settings.glassQuality === defaults.glassQuality &&
|
||||||
settings.glassReflectionStrength === defaults.glassReflectionStrength &&
|
settings.glassReflectionStrength === defaults.glassReflectionStrength &&
|
||||||
settings.glassTransmissionStrength === defaults.glassTransmissionStrength &&
|
settings.glassTransmissionStrength === defaults.glassTransmissionStrength &&
|
||||||
@@ -607,49 +684,137 @@ export function useThemeCustomizer() {
|
|||||||
return updateSettings({ primaryColor: color })
|
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) {
|
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) {
|
function setGlassDeformationStrength(glassDeformationStrength: number) {
|
||||||
return updateSettings({ glassDeformationStrength })
|
return updateGlassPresetOverride({ deformation: normalizeGlassOpticalStrength(glassDeformationStrength) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新玻璃轨迹、尾波与惯性强度。 */
|
/** 更新玻璃轨迹、尾波与惯性强度。 */
|
||||||
function setGlassFlowStrength(glassFlowStrength: number) {
|
function setGlassFlowStrength(glassFlowStrength: number) {
|
||||||
return updateSettings({ glassFlowStrength })
|
return updateGlassPresetOverride({ flow: normalizeGlassOpticalStrength(glassFlowStrength) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新当前玻璃方案,不隐式覆盖用户已保存的具体参数。 */
|
/** 切换方案时优先恢复该组合已保存的覆盖。 */
|
||||||
function setGlassPreset(glassPreset: GlassOpticalPreset) {
|
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) {
|
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) {
|
function setGlassReflectionStrength(glassReflectionStrength: number) {
|
||||||
return updateSettings({ glassReflectionStrength })
|
return updateGlassPresetOverride({ reflection: normalizeGlassOpticalStrength(glassReflectionStrength) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新玻璃内部壁纸采样的透射亮度。 */
|
/** 更新玻璃内部壁纸采样的透射亮度。 */
|
||||||
function setGlassTransmissionStrength(glassTransmissionStrength: number) {
|
function setGlassTransmissionStrength(glassTransmissionStrength: number) {
|
||||||
return updateSettings({ glassTransmissionStrength })
|
return updateGlassPresetOverride({ transmission: normalizeGlassOpticalStrength(glassTransmissionStrength) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新玻璃统一采样平移强度。 */
|
/** 更新玻璃统一采样平移强度。 */
|
||||||
function setGlassTranslationStrength(glassTranslationStrength: number) {
|
function setGlassTranslationStrength(glassTranslationStrength: number) {
|
||||||
return updateSettings({ glassTranslationStrength })
|
return updateGlassPresetOverride({ translation: normalizeGlassOpticalStrength(glassTranslationStrength) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新玻璃材质的真实壁纸可见度。 */
|
/** 更新玻璃材质的真实壁纸可见度。 */
|
||||||
function setGlassTransparencyStrength(glassTransparencyStrength: number) {
|
function setGlassTransparencyStrength(glassTransparencyStrength: number) {
|
||||||
return updateSettings({ glassTransparencyStrength })
|
return updateGlassPresetOverride({ transparency: normalizeGlassOpticalStrength(glassTransparencyStrength) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新全局圆角档位。 */
|
/** 更新全局圆角档位。 */
|
||||||
@@ -691,6 +856,7 @@ export function useThemeCustomizer() {
|
|||||||
glassDeformationStrength: glassParameters.deformation,
|
glassDeformationStrength: glassParameters.deformation,
|
||||||
glassFlowStrength: glassParameters.flow,
|
glassFlowStrength: glassParameters.flow,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
|
glassPresetOverrides: {},
|
||||||
glassQuality: defaultGlassQuality,
|
glassQuality: defaultGlassQuality,
|
||||||
glassReflectionStrength: glassParameters.reflection,
|
glassReflectionStrength: glassParameters.reflection,
|
||||||
glassTransmissionStrength: glassParameters.transmission,
|
glassTransmissionStrength: glassParameters.transmission,
|
||||||
|
|||||||
@@ -631,7 +631,7 @@ onUnmounted(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- 👉 UI模式设置 - 使用嵌套菜单 -->
|
<!-- 👉 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 }">
|
<template v-slot:activator="{ props: menuProps }">
|
||||||
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
@@ -666,7 +666,7 @@ onUnmounted(() => {
|
|||||||
</VMenu>
|
</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 }">
|
<template v-slot:activator="{ props: menuProps }">
|
||||||
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
@@ -730,7 +730,7 @@ onUnmounted(() => {
|
|||||||
</VMenu>
|
</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 }">
|
<template v-slot:activator="{ props: menuProps }">
|
||||||
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export default {
|
|||||||
glassOpticalStrengthHint:
|
glassOpticalStrengthHint:
|
||||||
'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.',
|
'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.',
|
||||||
glassOpticalStrengthUnavailableHint:
|
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',
|
purple: 'Purple',
|
||||||
custom: 'Custom Style',
|
custom: 'Custom Style',
|
||||||
transparency: 'Transparency',
|
transparency: 'Transparency',
|
||||||
@@ -3810,8 +3810,7 @@ export default {
|
|||||||
password: 'Password',
|
password: 'Password',
|
||||||
passwordHint: 'Login password',
|
passwordHint: 'Login password',
|
||||||
accessCode: 'Access Code',
|
accessCode: 'Access Code',
|
||||||
accessCodeHint:
|
accessCodeHint: 'Access code enabled in fnOS Settings -> Security -> Access Settings. Leave empty if not enabled',
|
||||||
'Access code enabled in fnOS Settings -> Security -> Access Settings. Leave empty if not enabled',
|
|
||||||
syncLibraries: 'Sync Libraries',
|
syncLibraries: 'Sync Libraries',
|
||||||
syncLibrariesHint: 'Only selected libraries will be synchronized',
|
syncLibrariesHint: 'Only selected libraries will be synchronized',
|
||||||
scanMode: 'Scan Mode',
|
scanMode: 'Scan Mode',
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export default {
|
|||||||
glassTransmissionStrength: '透射亮度',
|
glassTransmissionStrength: '透射亮度',
|
||||||
glassTransparencyStrength: '通透度',
|
glassTransparencyStrength: '通透度',
|
||||||
glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。',
|
glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。',
|
||||||
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数。切换到均衡或高质量后,可调整采样平移、形变和流动。',
|
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数,切换到均衡或高质量后,可调整采样平移、形变和流动。',
|
||||||
purple: '幻紫',
|
purple: '幻紫',
|
||||||
custom: '附加样式',
|
custom: '附加样式',
|
||||||
transparency: '透明度',
|
transparency: '透明度',
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export default {
|
|||||||
glassTransmissionStrength: '透射亮度',
|
glassTransmissionStrength: '透射亮度',
|
||||||
glassTransparencyStrength: '通透度',
|
glassTransparencyStrength: '通透度',
|
||||||
glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。',
|
glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。',
|
||||||
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數。切換到均衡或高品質後,可調整採樣平移、形變和流動。',
|
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數,切換到均衡或高品質後,可調整採樣平移、形變和流動。',
|
||||||
purple: '幻紫',
|
purple: '幻紫',
|
||||||
custom: '附加樣式',
|
custom: '附加樣式',
|
||||||
transparency: '透明度',
|
transparency: '透明度',
|
||||||
|
|||||||
@@ -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;/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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);
|
background-image: var(--plugin-card-banner-scrim), var(--plugin-card-banner-tint);
|
||||||
transition: background-image 0.2s ease;
|
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-scrim: linear-gradient(rgba(0, 0, 0, 60%) 0%, rgba(0, 0, 0, 50%) 100%);
|
||||||
--plugin-card-banner-tint: linear-gradient(
|
--plugin-card-banner-tint: linear-gradient(
|
||||||
rgb(var(--plugin-card-accent-rgb)) 0%,
|
rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 0%,
|
||||||
rgb(var(--plugin-card-accent-rgb)) 100%
|
rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
|
|
||||||
// 材质只覆盖统一表面 token;质量档只替换光学层,不改变业务组件契约。
|
// 材质只覆盖统一表面 token;质量档只替换光学层,不改变业务组件契约。
|
||||||
html[data-theme='glass'] {
|
html[data-theme='glass'] {
|
||||||
--glass-surface: rgba(11, 19, 34, calc(0.3 - 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.36 - var(--glass-transparency, 0.5) * 0.24));
|
--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.44 - var(--glass-transparency, 0.5) * 0.24));
|
--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: rgba(11, 19, 34, 52%);
|
||||||
--glass-control-prominent: rgba(255, 255, 255, 7%);
|
--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%));
|
--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%);
|
inset 0 -1px 0 rgba(2, 6, 16, 16%);
|
||||||
--glass-surface-backdrop-filter: brightness(var(--glass-transmission-brightness));
|
--glass-surface-backdrop-filter: brightness(var(--glass-transmission-brightness));
|
||||||
--glass-raised-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-backdrop-filter: none;
|
||||||
--glass-control-prominent-backdrop-filter: none;
|
--glass-control-prominent-backdrop-filter: none;
|
||||||
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
--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-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-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate));
|
||||||
--glass-control-icon-color: rgba(242, 245, 250, 70%);
|
--glass-control-icon-color: rgba(242, 245, 250, 70%);
|
||||||
--glass-control-placeholder-color: rgba(242, 245, 250, 54%);
|
--glass-control-placeholder-color: rgba(242, 245, 250, 54%);
|
||||||
@@ -112,18 +114,18 @@ html[data-theme='glass'] {
|
|||||||
&[data-glass-appearance='tinted'] {
|
&[data-glass-appearance='tinted'] {
|
||||||
--glass-surface: color-mix(
|
--glass-surface: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24)) 88%,
|
rgba(11, 19, 34, calc(0.02 + var(--glass-surface-density, 0.72) * 0.3)) 88%,
|
||||||
rgba(var(--v-theme-primary), 28%)
|
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.28))
|
||||||
);
|
);
|
||||||
--glass-surface-soft: color-mix(
|
--glass-surface-soft: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24)) 89%,
|
rgba(11, 19, 34, calc(0.04 + var(--glass-surface-density, 0.72) * 0.32)) 89%,
|
||||||
rgba(var(--v-theme-primary), 26%)
|
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.26))
|
||||||
);
|
);
|
||||||
--glass-surface-raised: color-mix(
|
--glass-surface-raised: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24)) 84%,
|
rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.72) * 0.36)) 84%,
|
||||||
rgba(var(--v-theme-primary), 32%)
|
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: 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%));
|
--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%,
|
rgba(var(--v-theme-primary), 6%) 72%,
|
||||||
transparent
|
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'] {
|
&[data-glass-appearance='frosted'] {
|
||||||
--glass-surface: rgba(255, 255, 255, calc(0.14 - var(--glass-transparency, 0.5) * 0.1));
|
--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.13 - var(--glass-transparency, 0.5) * 0.08));
|
--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.18 - var(--glass-transparency, 0.5) * 0.11));
|
--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: rgba(255, 255, 255, 9%);
|
||||||
--glass-control-prominent: rgba(255, 255, 255, 10%);
|
--glass-control-prominent: rgba(255, 255, 255, 10%);
|
||||||
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
|
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
|
||||||
@@ -194,13 +204,14 @@ html[data-theme='glass'] {
|
|||||||
brightness(var(--glass-transmission-brightness));
|
brightness(var(--glass-transmission-brightness));
|
||||||
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
|
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
|
||||||
brightness(var(--glass-transmission-brightness));
|
brightness(var(--glass-transmission-brightness));
|
||||||
|
// 固定全高导航限制采样半径,避免页面重绘扩大其 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-control-prominent-backdrop-filter: blur(24px) saturate(150%);
|
||||||
--glass-overlay-surface: rgba(
|
--glass-overlay-surface: rgba(var(--v-theme-background), calc(0.24 + var(--glass-surface-density, 0.86) * 0.12));
|
||||||
var(--v-theme-background),
|
--glass-overlay-blur: min(var(--glass-blur-raised), 36px);
|
||||||
calc(0.76 - var(--glass-transparency, 0.5) * 0.12)
|
--glass-overlay-saturate: 135%;
|
||||||
);
|
--glass-overlay-scrim: rgba(3, 7, 18, 36%);
|
||||||
--glass-overlay-blur: var(--glass-blur-raised);
|
|
||||||
--glass-overlay-saturate: var(--glass-saturate);
|
|
||||||
--glass-blur-surface: 40px;
|
--glass-blur-surface: 40px;
|
||||||
--glass-blur: 40px;
|
--glass-blur: 40px;
|
||||||
--glass-blur-raised: 60px;
|
--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))
|
--glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate))
|
||||||
brightness(var(--glass-transmission-brightness));
|
brightness(var(--glass-transmission-brightness));
|
||||||
|
|
||||||
.v-overlay__scrim {
|
|
||||||
background: rgba(3, 7, 18, 62%);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 磨砂弹窗只由最外层表面采样背景,内部卡片与自定义分组表面保持扁平。
|
// 磨砂弹窗只由最外层表面采样背景,内部卡片与自定义分组表面保持扁平。
|
||||||
.v-overlay__content > :where(.v-card, .v-sheet),
|
.v-overlay__content > :where(.v-card, .v-sheet),
|
||||||
.v-overlay__content > form > :where(.v-card, .v-sheet) {
|
.v-overlay__content > form > :where(.v-card, .v-sheet) {
|
||||||
@@ -438,8 +445,8 @@ html[data-theme='glass'] {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
border-inline-end: 1px solid var(--glass-border-raised);
|
border-inline-end: 1px solid var(--glass-border-raised);
|
||||||
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
-webkit-backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
|
||||||
backdrop-filter: var(--glass-raised-backdrop-filter);
|
backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
|
||||||
background-color: var(--glass-surface-raised);
|
background-color: var(--glass-surface-raised);
|
||||||
background-image: var(--glass-sheen);
|
background-image: var(--glass-sheen);
|
||||||
box-shadow: var(--glass-shadow-raised);
|
box-shadow: var(--glass-shadow-raised);
|
||||||
@@ -575,7 +582,7 @@ html[data-theme='glass'] {
|
|||||||
.v-overlay__scrim {
|
.v-overlay__scrim {
|
||||||
-webkit-backdrop-filter: none;
|
-webkit-backdrop-filter: none;
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
background: rgba(3, 7, 18, 48%);
|
background: var(--glass-overlay-scrim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-dialog--fullscreen {
|
.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'] {
|
&[data-glass-appearance='clear'] {
|
||||||
.layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar,
|
.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);
|
border-block-end: 1px solid var(--glass-border);
|
||||||
|
|
||||||
--plugin-card-banner-scrim: linear-gradient(
|
--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.2 + var(--glass-surface-density, 0.62) * 0.115)) 0%,
|
||||||
rgba(11, 19, 34, calc(0.46 - var(--glass-transparency, 0.5) * 0.16)) 100%
|
rgba(11, 19, 34, calc(0.28 + var(--glass-surface-density, 0.62) * 0.16)) 100%
|
||||||
);
|
);
|
||||||
--plugin-card-banner-tint: linear-gradient(
|
--plugin-card-banner-tint: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(var(--plugin-card-accent-rgb), 0.42) 0%,
|
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), 0.24) 58%,
|
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), 0.12) 100%
|
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 {
|
&[data-glass-appearance='frosted'] .plugin-card__banner {
|
||||||
--plugin-card-banner-scrim: linear-gradient(
|
--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.1 + var(--glass-surface-density, 0.86) * 0.09)) 0%,
|
||||||
rgba(11, 19, 34, calc(0.34 - var(--glass-transparency, 0.5) * 0.14)) 100%
|
rgba(11, 19, 34, calc(0.14 + var(--glass-surface-density, 0.86) * 0.145)) 100%
|
||||||
);
|
);
|
||||||
--plugin-card-banner-tint: linear-gradient(
|
--plugin-card-banner-tint: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(var(--plugin-card-accent-rgb), 0.32) 0%,
|
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), 0.18) 58%,
|
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), 0.08) 100%
|
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 {
|
&[data-glass-appearance='tinted'] .plugin-card__banner {
|
||||||
--plugin-card-banner-tint: linear-gradient(
|
--plugin-card-banner-tint: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
color-mix(in srgb, rgba(var(--plugin-card-accent-rgb), 0.42) 74%, rgba(var(--v-theme-primary), 0.42)) 0%,
|
color-mix(
|
||||||
color-mix(in srgb, rgba(var(--plugin-card-accent-rgb), 0.24) 74%, rgba(var(--v-theme-primary), 0.24)) 58%,
|
in srgb,
|
||||||
rgba(var(--plugin-card-accent-rgb), 0.12) 100%
|
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 {
|
.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 {
|
.plugin-folder-card__bg::after {
|
||||||
@@ -879,8 +915,8 @@ html[data-theme='glass'] {
|
|||||||
|
|
||||||
.plugin-folder-card__overlay {
|
.plugin-folder-card__overlay {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
rgba(11, 19, 34, calc(0.34 - var(--glass-transparency, 0.5) * 0.14)) 0%,
|
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.115)) 0%,
|
||||||
rgba(11, 19, 34, calc(0.46 - var(--glass-transparency, 0.5) * 0.16)) 100%
|
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;
|
border-color: var(--glass-border-hover) !important;
|
||||||
box-shadow: var(--glass-shadow-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 {
|
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):active {
|
||||||
@@ -1123,7 +1164,6 @@ html[data-theme='glass'] {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 180ms ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-optical-layer--fixed {
|
.glass-optical-layer--fixed {
|
||||||
@@ -1152,6 +1192,7 @@ html[data-theme='glass']:is(
|
|||||||
)[data-glass-renderer-state='ready'] {
|
)[data-glass-renderer-state='ready'] {
|
||||||
--glass-surface-backdrop-filter: none;
|
--glass-surface-backdrop-filter: none;
|
||||||
--glass-raised-backdrop-filter: none;
|
--glass-raised-backdrop-filter: none;
|
||||||
|
--glass-fixed-shell-backdrop-filter: none;
|
||||||
--glass-navbar-backdrop-filter: var(--glass-surface-backdrop-filter);
|
--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-quality='high']
|
||||||
)[data-glass-renderer-state='ready'] {
|
)[data-glass-renderer-state='ready'] {
|
||||||
--glass-navbar-backdrop-filter: none;
|
--glass-navbar-backdrop-filter: none;
|
||||||
--glass-overlay-blur: 0px;
|
|
||||||
--glass-overlay-saturate: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
GLASS_OPTICAL_MOTION_MAX_SCALE,
|
GLASS_OPTICAL_MOTION_MAX_SCALE,
|
||||||
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
|
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
|
||||||
getAvailableGlassOpticalPresets,
|
getAvailableGlassOpticalPresets,
|
||||||
|
getGlassCssFrostBlur,
|
||||||
getGlassCoverScale,
|
getGlassCoverScale,
|
||||||
|
getGlassMaterialResponse,
|
||||||
getGlassOpticalCssTransmissionBrightness,
|
getGlassOpticalCssTransmissionBrightness,
|
||||||
getGlassOpticalDecay,
|
getGlassOpticalDecay,
|
||||||
getGlassOpticalBufferSize,
|
getGlassOpticalBufferSize,
|
||||||
@@ -12,6 +14,8 @@ import {
|
|||||||
getGlassOpticalMotionExpansion,
|
getGlassOpticalMotionExpansion,
|
||||||
getGlassOpticalMotionStrengthScale,
|
getGlassOpticalMotionStrengthScale,
|
||||||
getGlassOpticalPresetParameters,
|
getGlassOpticalPresetParameters,
|
||||||
|
getGlassOpticalPresetParametersWithOverrides,
|
||||||
|
getGlassOpticalPresetKey,
|
||||||
getGlassOpticalReflectionStrengthScale,
|
getGlassOpticalReflectionStrengthScale,
|
||||||
getGlassOpticalRenderProfile,
|
getGlassOpticalRenderProfile,
|
||||||
getGlassOpticalTransparency,
|
getGlassOpticalTransparency,
|
||||||
@@ -57,7 +61,9 @@ describe('glass optics geometry', () => {
|
|||||||
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
|
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
|
||||||
getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25),
|
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(80)).toBeGreaterThan(0.4)
|
||||||
expect(getGlassOpticalMotionExpansion(100)).toBe(1)
|
expect(getGlassOpticalMotionExpansion(100)).toBe(1)
|
||||||
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
|
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
|
||||||
@@ -87,12 +93,12 @@ describe('glass optics geometry', () => {
|
|||||||
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
|
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
|
||||||
|
|
||||||
expect(natural).toEqual({
|
expect(natural).toEqual({
|
||||||
deformation: 50,
|
deformation: 40,
|
||||||
flow: 50,
|
flow: 40,
|
||||||
reflection: 35,
|
reflection: 35,
|
||||||
transmission: 70,
|
transmission: 54,
|
||||||
translation: 50,
|
translation: 40,
|
||||||
transparency: 70,
|
transparency: 46,
|
||||||
})
|
})
|
||||||
expect(glide.translation).toBeGreaterThan(glide.deformation)
|
expect(glide.translation).toBeGreaterThan(glide.deformation)
|
||||||
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
|
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
|
||||||
@@ -101,11 +107,166 @@ describe('glass optics geometry', () => {
|
|||||||
expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid'])
|
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', () => {
|
it('returns preset copies so previews cannot mutate the shared matrix', () => {
|
||||||
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
|
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
|
||||||
first.translation = 0
|
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', () => {
|
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', () => {
|
it('crossfades active surface weights monotonically', () => {
|
||||||
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 })
|
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 1, outgoing: 1 })
|
||||||
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 })
|
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 1, outgoing: 0.5 })
|
||||||
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
|
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, getGlassWallpaperToneProfile } from '@/utils/glassWallpaperTone'
|
import {
|
||||||
import { describe, expect, it } from 'vitest'
|
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||||
|
getGlassWallpaperToneProfile,
|
||||||
|
loadGlassWallpaperTone,
|
||||||
|
takeGlassWallpaperDecodedSource,
|
||||||
|
} from '@/utils/glassWallpaperTone'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
describe('glass wallpaper tone profile', () => {
|
describe('glass wallpaper tone profile', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a representative mid-tone wallpaper near neutral exposure', () => {
|
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])
|
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', () => {
|
it('falls back to the neutral profile when no valid samples exist', () => {
|
||||||
expect(getGlassWallpaperToneProfile([Number.NaN])).toEqual(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+125
-40
@@ -13,6 +13,8 @@ export const GLASS_OPTICAL_REFERENCE_STRENGTH = 70
|
|||||||
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
|
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
|
||||||
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
|
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
|
||||||
export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural'
|
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 GlassOpticalQuality = 'balanced' | 'high'
|
||||||
export type GlassCornerRadii = [number, number, number, number]
|
export type GlassCornerRadii = [number, number, number, number]
|
||||||
|
|
||||||
@@ -31,6 +33,19 @@ export interface GlassOpticalParameters {
|
|||||||
transparency: number
|
transparency: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GlassMaterialResponse {
|
||||||
|
/** 真实壁纸在表面材质后的感知可见程度。 */
|
||||||
|
backgroundVisibility: number
|
||||||
|
/** 磨砂预滤纹理允许保留的高频细节比例。 */
|
||||||
|
frostDetailLevel: number
|
||||||
|
/** 标准档 CSS 磨砂半径相对既有 40px 基线的缩放。 */
|
||||||
|
frostBlurScale: number
|
||||||
|
/** 表面遮罩对真实壁纸的覆盖密度。 */
|
||||||
|
surfaceDensity: number
|
||||||
|
/** 色调材质的主体染色密度。 */
|
||||||
|
tintDensity: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface GlassInteractionPoint {
|
export interface GlassInteractionPoint {
|
||||||
/** 指针或触点相对视口的横坐标。 */
|
/** 指针或触点相对视口的横坐标。 */
|
||||||
x: number
|
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)))
|
return Math.min(GLASS_OPTICAL_STRENGTH_MAX, Math.max(GLASS_OPTICAL_STRENGTH_MIN, Math.round(value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const GLASS_OPTICAL_PRESET_MATRIX: Record<
|
type GlassOpticalPresetSet = Record<GlassOpticalPreset, GlassOpticalParameters>
|
||||||
GlassAppearance,
|
type GlassOpticalCapabilityPresets = {
|
||||||
Record<GlassOpticalCapability, Record<GlassOpticalPreset, GlassOpticalParameters>>
|
balanced: GlassOpticalPresetSet
|
||||||
> = {
|
css: Pick<GlassOpticalPresetSet, 'natural'>
|
||||||
|
high: GlassOpticalPresetSet
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLASS_OPTICAL_PRESET_MATRIX: Record<GlassAppearance, GlassOpticalCapabilityPresets> = {
|
||||||
clear: {
|
clear: {
|
||||||
css: {
|
css: {
|
||||||
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
|
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 56, translation: 40, transparency: 48 },
|
||||||
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 },
|
|
||||||
},
|
},
|
||||||
balanced: {
|
balanced: {
|
||||||
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
|
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 46 },
|
||||||
glide: { deformation: 30, flow: 44, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
|
glide: { deformation: 24, flow: 35, reflection: 29, transmission: 58, translation: 58, transparency: 56 },
|
||||||
liquid: { deformation: 70, flow: 76, reflection: 36, transmission: 66, translation: 56, transparency: 74 },
|
liquid: { deformation: 56, flow: 61, reflection: 36, transmission: 51, translation: 45, transparency: 51 },
|
||||||
},
|
},
|
||||||
high: {
|
high: {
|
||||||
natural: { deformation: 50, flow: 50, reflection: 32, transmission: 70, translation: 50, transparency: 70 },
|
natural: { deformation: 40, flow: 40, reflection: 32, transmission: 53, translation: 40, transparency: 45 },
|
||||||
glide: { deformation: 32, flow: 46, reflection: 28, transmission: 74, translation: 74, transparency: 80 },
|
glide: { deformation: 26, flow: 37, reflection: 28, transmission: 56, translation: 59, transparency: 54 },
|
||||||
liquid: { deformation: 74, flow: 80, reflection: 35, transmission: 68, translation: 58, transparency: 76 },
|
liquid: { deformation: 59, flow: 64, reflection: 35, transmission: 50, translation: 46, transparency: 50 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tinted: {
|
tinted: {
|
||||||
css: {
|
css: {
|
||||||
natural: { deformation: 50, flow: 50, reflection: 38, transmission: 68, translation: 50, transparency: 46 },
|
natural: { deformation: 40, flow: 40, reflection: 38, transmission: 54, translation: 40, transparency: 34 },
|
||||||
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 },
|
|
||||||
},
|
},
|
||||||
balanced: {
|
balanced: {
|
||||||
natural: { deformation: 52, flow: 50, reflection: 38, transmission: 72, translation: 50, transparency: 46 },
|
natural: { deformation: 42, flow: 40, reflection: 38, transmission: 56, translation: 40, transparency: 32 },
|
||||||
glide: { deformation: 32, flow: 44, reflection: 34, transmission: 78, translation: 70, transparency: 52 },
|
glide: { deformation: 26, flow: 35, reflection: 34, transmission: 61, translation: 56, transparency: 40 },
|
||||||
liquid: { deformation: 72, flow: 76, reflection: 39, transmission: 68, translation: 56, transparency: 48 },
|
liquid: { deformation: 58, flow: 61, reflection: 39, transmission: 53, translation: 45, transparency: 36 },
|
||||||
},
|
},
|
||||||
high: {
|
high: {
|
||||||
natural: { deformation: 52, flow: 50, reflection: 35, transmission: 76, translation: 50, transparency: 48 },
|
natural: { deformation: 42, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 30 },
|
||||||
glide: { deformation: 34, flow: 46, reflection: 32, transmission: 82, translation: 72, transparency: 54 },
|
glide: { deformation: 27, flow: 37, reflection: 32, transmission: 59, translation: 58, transparency: 38 },
|
||||||
liquid: { deformation: 76, flow: 80, reflection: 38, transmission: 72, translation: 58, transparency: 50 },
|
liquid: { deformation: 61, flow: 64, reflection: 38, transmission: 51, translation: 46, transparency: 34 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
frosted: {
|
frosted: {
|
||||||
css: {
|
css: {
|
||||||
natural: { deformation: 50, flow: 50, reflection: 31, transmission: 54, translation: 50, transparency: 42 },
|
natural: { deformation: 40, flow: 40, reflection: 31, transmission: 50, translation: 40, transparency: 31 },
|
||||||
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 },
|
|
||||||
},
|
},
|
||||||
balanced: {
|
balanced: {
|
||||||
natural: { deformation: 58, flow: 52, reflection: 31, transmission: 58, translation: 48, transparency: 42 },
|
natural: { deformation: 46, flow: 42, reflection: 31, transmission: 52, translation: 38, transparency: 29 },
|
||||||
glide: { deformation: 38, flow: 44, reflection: 27, transmission: 62, translation: 68, transparency: 46 },
|
glide: { deformation: 30, flow: 35, reflection: 27, transmission: 56, translation: 54, transparency: 40 },
|
||||||
liquid: { deformation: 78, flow: 76, reflection: 32, transmission: 54, translation: 52, transparency: 44 },
|
liquid: { deformation: 62, flow: 61, reflection: 32, transmission: 49, translation: 42, transparency: 34 },
|
||||||
},
|
},
|
||||||
high: {
|
high: {
|
||||||
natural: { deformation: 60, flow: 52, reflection: 29, transmission: 62, translation: 48, transparency: 44 },
|
natural: { deformation: 48, flow: 42, reflection: 29, transmission: 50, translation: 38, transparency: 27 },
|
||||||
glide: { deformation: 40, flow: 46, reflection: 25, transmission: 66, translation: 70, transparency: 48 },
|
glide: { deformation: 32, flow: 37, reflection: 25, transmission: 54, translation: 56, transparency: 38 },
|
||||||
liquid: { deformation: 82, flow: 80, reflection: 31, transmission: 58, translation: 54, transparency: 46 },
|
liquid: { deformation: 66, flow: 64, reflection: 31, transmission: 47, translation: 43, transparency: 32 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -191,7 +204,10 @@ export function getGlassOpticalPresetParameters(
|
|||||||
quality: GlassOpticalCapability,
|
quality: GlassOpticalCapability,
|
||||||
preset: GlassOpticalPreset,
|
preset: GlassOpticalPreset,
|
||||||
): GlassOpticalParameters {
|
): 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']
|
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 双纹理保持同一时钟。 */
|
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
|
||||||
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
|
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
|
||||||
if (duration <= 0 || elapsed >= duration) return 1
|
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)
|
return normalizedRatio ** Math.log2(GLASS_OPTICAL_FLOW_MAX_SCALE)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 高于默认值的流动强度逐步扩大作用范围,低区间不会意外改变既有空间尺度。 */
|
/** 流动强度在完整滑杆区间连续控制轨迹范围,低值也能明显收紧空间足迹。 */
|
||||||
export function getGlassOpticalMotionExpansion(value: unknown) {
|
export function getGlassOpticalMotionExpansion(value: unknown) {
|
||||||
const normalized = normalizeGlassOpticalStrength(value)
|
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
|
return directionDot < Math.cos((55 * Math.PI) / 180) ? nextDirection : currentDirection
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 活动表面立即可感知,随后与离场表面完成短时单调交叉过渡。 */
|
/** 新活动表面立即接管输入,离场表面只做短时单调淡出。 */
|
||||||
export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) {
|
export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) {
|
||||||
const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration)))
|
const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration)))
|
||||||
const eased = progress * progress * (3 - 2 * progress)
|
const eased = progress * progress * (3 - 2 * progress)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
incoming: 0.35 + eased * 0.65,
|
incoming: 1,
|
||||||
outgoing: 1 - eased,
|
outgoing: 1 - eased,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-15
@@ -1,3 +1,5 @@
|
|||||||
|
import { preloadCorsImage } from '@/@core/utils/corsImage'
|
||||||
|
|
||||||
export interface GlassWallpaperToneProfile {
|
export interface GlassWallpaperToneProfile {
|
||||||
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
|
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
|
||||||
exposure: number
|
exposure: number
|
||||||
@@ -7,7 +9,24 @@ export interface GlassWallpaperToneProfile {
|
|||||||
medianLuminance: number
|
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 ANALYSIS_MAX_EDGE = 64
|
||||||
|
const DECODED_SOURCE_CACHE_LIMIT = 3
|
||||||
const PROFILE_CACHE_LIMIT = 32
|
const PROFILE_CACHE_LIMIT = 32
|
||||||
const PROFILE_LOAD_TIMEOUT_MS = 3000
|
const PROFILE_LOAD_TIMEOUT_MS = 3000
|
||||||
const EXPOSURE_MIN = 0.88
|
const EXPOSURE_MIN = 0.88
|
||||||
@@ -21,7 +40,8 @@ export const DEFAULT_GLASS_WALLPAPER_TONE_PROFILE: GlassWallpaperToneProfile = {
|
|||||||
medianLuminance: MEDIAN_TARGET,
|
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) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.min(max, Math.max(min, value))
|
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) {
|
if (profileCache.size >= PROFILE_CACHE_LIMIT) {
|
||||||
const oldestKey = profileCache.keys().next().value
|
const oldestKey = profileCache.keys().next().value
|
||||||
if (oldestKey) profileCache.delete(oldestKey)
|
if (oldestKey) profileCache.delete(oldestKey)
|
||||||
@@ -96,37 +116,105 @@ function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile
|
|||||||
return profile
|
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,
|
* renderer 取得已完成 tone 分析的图片后立即移出缓存。
|
||||||
* 避免亮度分析阻断壁纸本身的 CSS 显示能力。
|
* fixed 与 scroll context 后续通过 renderer 自身的 CPU source cache 共享缩放结果。
|
||||||
*/
|
*/
|
||||||
export function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
|
export function takeGlassWallpaperDecodedSource(url: string): GlassWallpaperDecodedSource | undefined {
|
||||||
if (!url || typeof Image === 'undefined') return Promise.resolve({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
|
const source = decodedSourceCache.get(url)
|
||||||
|
if (source) decodedSourceCache.delete(url)
|
||||||
|
|
||||||
const cached = profileCache.get(url)
|
return source
|
||||||
if (cached) return cached
|
}
|
||||||
|
|
||||||
const profile = new Promise<GlassWallpaperToneProfile>(resolve => {
|
function loadCorsReadableToneProfile(url: string): Promise<GlassWallpaperToneLoadResult> {
|
||||||
|
return new Promise(resolve => {
|
||||||
const image = new Image()
|
const image = new Image()
|
||||||
let settled = false
|
let settled = false
|
||||||
const finish = (result: GlassWallpaperToneProfile) => {
|
const finish = (result: GlassWallpaperToneLoadResult) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
resolve(result)
|
resolve(result)
|
||||||
}
|
}
|
||||||
const timeout = window.setTimeout(
|
const timeout = window.setTimeout(
|
||||||
() => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }),
|
() => finish({ corsReady: false, profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE } }),
|
||||||
PROFILE_LOAD_TIMEOUT_MS,
|
PROFILE_LOAD_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
|
|
||||||
image.crossOrigin = 'anonymous'
|
image.crossOrigin = 'anonymous'
|
||||||
image.decoding = 'async'
|
image.decoding = 'async'
|
||||||
image.onload = () =>
|
image.onload = () => {
|
||||||
finish(analyzeGlassWallpaperTone(image, image.naturalWidth || image.width, image.naturalHeight || image.height))
|
const profile = analyzeGlassWallpaperTone(
|
||||||
image.onerror = () => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
|
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
|
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