diff --git a/src/App.vue b/src/App.vue index dfd6e485..a2a0f29e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,6 +12,12 @@ import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundMan import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue' import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer' +import { + applyStoredTransparencySettings, + TRANSPARENCY_SETTINGS_CHANGED_EVENT, + type TransparencyGlassQuality, + type TransparencySettings, +} from '@/composables/useTransparencySettings' import { completeLaunchLoading } from '@/composables/useLaunchLoading' import { usePWA } from '@/composables/usePWA' import { themeManager } from '@/utils/themeManager' @@ -60,29 +66,38 @@ const backgroundImages = ref([]) const activeImageIndex = ref(0) const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent') const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) +const shouldUseTransparentBackgroundTreatment = computed( + () => (Boolean(isLogin.value) && isTransparentTheme.value) || isLoginWallpaperRoute.value, +) const shouldLoadBackgroundImages = computed( () => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value), ) +const transparentBackgroundBlur = ref(16) +const transparencyGlassQuality = ref( + localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight', +) +const shouldRenderGlobalBlurLayer = computed( + () => + shouldUseTransparentBackgroundTreatment.value && + transparentBackgroundBlur.value > 0 && + transparencyGlassQuality.value === 'realtime', +) let backgroundRetryTimer: number | null = null let backgroundRequestController: AbortController | null = null let authenticatedStateTimer: number | null = null -function getStoredNumber(key: string, fallback: number, min: number, max: number) { - const parsed = Number.parseFloat(localStorage.getItem(key) || '') - if (!Number.isFinite(parsed)) return fallback +function applyTransparentBackgroundSettings() { + const settings = applyStoredTransparencySettings() - return Math.min(max, Math.max(min, parsed)) + transparentBackgroundBlur.value = settings.backgroundBlur + transparencyGlassQuality.value = settings.glassQuality } -function applyTransparentBackgroundSettings() { - document.documentElement.style.setProperty( - '--transparent-background-poster-opacity', - (1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(), - ) - document.documentElement.style.setProperty( - '--transparent-background-blur', - `${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`, - ) +function handleTransparencySettingsChanged(event: Event) { + const { backgroundBlur, glassQuality } = (event as CustomEvent).detail + + transparentBackgroundBlur.value = backgroundBlur + transparencyGlassQuality.value = glassQuality } applyTransparentBackgroundSettings() @@ -360,6 +375,7 @@ onMounted(async () => { document.addEventListener('visibilitychange', handleVisibilityThemeSync) window.addEventListener('pageshow', handlePageShowThemeSync) window.addEventListener('focus', handlePageShowThemeSync) + window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged) // 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。 watch( @@ -413,6 +429,7 @@ onUnmounted(() => { document.removeEventListener('visibilitychange', handleVisibilityThemeSync) window.removeEventListener('pageshow', handlePageShowThemeSync) window.removeEventListener('focus', handlePageShowThemeSync) + window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged) }) @@ -422,7 +439,11 @@ onUnmounted(() => {
{ :style="{ 'backgroundImage': `url(${imageUrl})` }" /> -
+
@@ -494,6 +515,17 @@ onUnmounted(() => { opacity: var(--transparent-background-poster-opacity, 1); } +.background-container.is-transparent-glass-lightweight .background-image.active { + filter: blur(var(--transparent-background-blur, 16px)); + transform: scale(1.03); +} + +.background-container.is-transparent-glass-lightweight .background-image.active::after { + background: + linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%), + rgba(128, 128, 128, 30%); +} + /* 全局磨砂层 */ .global-blur-layer { position: absolute; diff --git a/src/components/dialog/TransparencySettingsDialog.vue b/src/components/dialog/TransparencySettingsDialog.vue index d7f60991..86ea7d8c 100644 --- a/src/components/dialog/TransparencySettingsDialog.vue +++ b/src/components/dialog/TransparencySettingsDialog.vue @@ -38,9 +38,11 @@ const { onBackgroundBlurChange, onBackgroundPosterOpacityChange, onBlurChange, + onGlassQualityChange, onOpacityChange, resetTransparencySettings, transparencyBlur, + transparencyGlassQuality, transparencyOpacity, } = useTransparencySettings() @@ -118,6 +120,34 @@ const { />
+
+ {{ t('theme.transparencyGlassQuality') }} + + + {{ t('theme.transparencyGlassQualityLightweight') }} + + + {{ t('theme.transparencyGlassQualityRealtime') }} + + +

+ {{ + transparencyGlassQuality === 'realtime' + ? t('theme.transparencyGlassQualityRealtimeHint') + : t('theme.transparencyGlassQualityLightweightHint') + }} +

+
+
{{ t('common.preset') }} diff --git a/src/composables/useTransparencySettings.ts b/src/composables/useTransparencySettings.ts index f29d0e80..562dcb0d 100644 --- a/src/composables/useTransparencySettings.ts +++ b/src/composables/useTransparencySettings.ts @@ -1,24 +1,36 @@ import { computed, ref } from 'vue' +export type TransparencyGlassQuality = 'lightweight' | 'realtime' + export interface TransparencySettings { backgroundBlur: number backgroundPosterOpacity: number blur: number + glassQuality: TransparencyGlassQuality level: string opacity: number } export const transparencyPresets = { - low: { opacity: 0.1, blur: 5 }, + low: { opacity: 0.6, blur: 5 }, medium: { opacity: 0.3, blur: 10 }, - high: { opacity: 0.6, blur: 15 }, + high: { opacity: 0.1, blur: 15 }, } +export const TRANSPARENCY_SETTINGS_CHANGED_EVENT = 'transparency-settings-changed' + /** 将数值限制在指定范围内。 */ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } +/** 读取玻璃质量档位,未知值回落到默认的轻量玻璃路径。 */ +function readGlassQuality(): TransparencyGlassQuality { + const storedQuality = localStorage.getItem('transparency-glass-quality') + + return storedQuality === 'realtime' ? 'realtime' : 'lightweight' +} + /** 从本地存储读取透明主题设置。 */ export function readTransparencySettings(): TransparencySettings { return { @@ -26,6 +38,7 @@ export function readTransparencySettings(): TransparencySettings { blur: parseFloat(localStorage.getItem('transparency-blur') || '10'), backgroundPosterOpacity: parseFloat(localStorage.getItem('transparency-background-poster-opacity') || '0'), backgroundBlur: parseFloat(localStorage.getItem('transparency-background-blur') || '16'), + glassQuality: readGlassQuality(), level: localStorage.getItem('transparency-level') || 'medium', } } @@ -39,6 +52,7 @@ export function applyTransparencySettings(settings: TransparencySettings) { ? clamp(settings.backgroundPosterOpacity, 0, 1) : 0, backgroundBlur: Number.isFinite(settings.backgroundBlur) ? clamp(settings.backgroundBlur, 0, 30) : 16, + glassQuality: settings.glassQuality === 'realtime' ? 'realtime' : 'lightweight', level: settings.level, } @@ -51,13 +65,20 @@ export function applyTransparencySettings(settings: TransparencySettings) { root.style.setProperty('--transparent-blur-heavy', `${normalized.blur * 1.6}px`) root.style.setProperty('--transparent-background-poster-opacity', (1 - normalized.backgroundPosterOpacity).toString()) root.style.setProperty('--transparent-background-blur', `${normalized.backgroundBlur}px`) + root.classList.toggle('transparent-blur-disabled', normalized.blur <= 0) + root.classList.toggle('transparent-background-blur-disabled', normalized.backgroundBlur <= 0) + root.classList.toggle('transparent-glass-lightweight', normalized.glassQuality === 'lightweight') + root.classList.toggle('transparent-glass-realtime', normalized.glassQuality === 'realtime') localStorage.setItem('transparency-opacity', normalized.opacity.toString()) localStorage.setItem('transparency-blur', normalized.blur.toString()) localStorage.setItem('transparency-background-poster-opacity', normalized.backgroundPosterOpacity.toString()) localStorage.setItem('transparency-background-blur', normalized.backgroundBlur.toString()) + localStorage.setItem('transparency-glass-quality', normalized.glassQuality) localStorage.setItem('transparency-level', normalized.level) + window.dispatchEvent(new CustomEvent(TRANSPARENCY_SETTINGS_CHANGED_EVENT, { detail: normalized })) + return normalized } @@ -73,6 +94,7 @@ export function useTransparencySettings() { const transparencyBlur = ref(storedSettings.blur) const backgroundPosterOpacity = ref(storedSettings.backgroundPosterOpacity) const backgroundBlur = ref(storedSettings.backgroundBlur) + const transparencyGlassQuality = ref(storedSettings.glassQuality) const transparencyLevel = ref(storedSettings.level) const currentPresetLevel = computed(() => { @@ -95,6 +117,7 @@ export function useTransparencySettings() { blur: transparencyBlur.value, backgroundPosterOpacity: backgroundPosterOpacity.value, backgroundBlur: backgroundBlur.value, + glassQuality: transparencyGlassQuality.value, level: transparencyLevel.value, }) @@ -102,6 +125,7 @@ export function useTransparencySettings() { transparencyBlur.value = normalized.blur backgroundPosterOpacity.value = normalized.backgroundPosterOpacity backgroundBlur.value = normalized.backgroundBlur + transparencyGlassQuality.value = normalized.glassQuality transparencyLevel.value = normalized.level } @@ -149,12 +173,18 @@ export function useTransparencySettings() { syncTransparencySettings() } + /** 处理玻璃质量档位变化。 */ + function onGlassQualityChange() { + syncTransparencySettings() + } + /** 重置透明主题设置为默认值。 */ function resetTransparencySettings() { transparencyOpacity.value = transparencyPresets.medium.opacity transparencyBlur.value = transparencyPresets.medium.blur backgroundPosterOpacity.value = 0 backgroundBlur.value = 16 + transparencyGlassQuality.value = 'lightweight' transparencyLevel.value = 'medium' syncTransparencySettings() } @@ -167,10 +197,12 @@ export function useTransparencySettings() { onBackgroundBlurChange, onBackgroundPosterOpacityChange, onBlurChange, + onGlassQualityChange, onOpacityChange, resetTransparencySettings, syncTransparencySettings, transparencyBlur, + transparencyGlassQuality, transparencyOpacity, transparencyLevel, } diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 518b4b1e..a3961500 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -154,6 +154,13 @@ export default { transparencyBlur: 'Blur', backgroundPosterOpacity: 'Background Opacity', backgroundBlur: 'Background Frosted Blur', + transparencyGlassQuality: 'Glass Effect Quality', + transparencyGlassQualityLightweight: 'Lightweight', + transparencyGlassQualityRealtime: 'Realtime', + transparencyGlassQualityLightweightHint: + 'Keeps a similar translucent glass look while reducing live background sampling on dense pages.', + transparencyGlassQualityRealtimeHint: + 'Uses the full live glass rendering path for higher visual quality, but may increase GPU usage.', transparencyReset: 'Reset', transparencyLow: 'Low Transparency', transparencyMedium: 'Medium Transparency', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index ce2e4f7f..1d9d5659 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -152,6 +152,11 @@ export default { transparencyBlur: '模糊度', backgroundPosterOpacity: '背景透明度', backgroundBlur: '背景磨砂效果', + transparencyGlassQuality: '玻璃效果质量', + transparencyGlassQualityLightweight: '轻量', + transparencyGlassQualityRealtime: '实时', + transparencyGlassQualityLightweightHint: '保持接近实时毛玻璃的透明观感,减少密集页面的背景实时采样。', + transparencyGlassQualityRealtimeHint: '使用完整实时毛玻璃路径,视觉质量更高,但可能增加 GPU 占用。', transparencyReset: '重置', transparencyLow: '低透明度', transparencyMedium: '中等透明度', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index ecb8ea57..45834157 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -152,6 +152,11 @@ export default { transparencyBlur: '模糊度', backgroundPosterOpacity: '背景透明度', backgroundBlur: '背景磨砂效果', + transparencyGlassQuality: '玻璃效果品質', + transparencyGlassQualityLightweight: '輕量', + transparencyGlassQualityRealtime: '即時', + transparencyGlassQualityLightweightHint: '保持接近即時毛玻璃的透明觀感,減少密集頁面的背景即時取樣。', + transparencyGlassQualityRealtimeHint: '使用完整即時毛玻璃路徑,視覺品質更高,但可能增加 GPU 佔用。', transparencyReset: '重置', transparencyLow: '低透明度', transparencyMedium: '中等透明度', diff --git a/src/pages/recommend.vue b/src/pages/recommend.vue index a9115632..c42d1625 100644 --- a/src/pages/recommend.vue +++ b/src/pages/recommend.vue @@ -348,6 +348,11 @@ onActivated(async () => { transition: opacity 0.3s ease; } +.content-group :deep(.slider-content-wrapper) { + content-visibility: auto; + contain-intrinsic-block-size: 16rem; +} + .empty-category { display: flex; flex-direction: column; diff --git a/src/styles/common.scss b/src/styles/common.scss index b95240fd..31d78f8c 100644 --- a/src/styles/common.scss +++ b/src/styles/common.scss @@ -707,6 +707,13 @@ html[data-theme="transparent"] .app-card-colorful, --app-surface-border: 0 solid transparent; } +html[data-theme="transparent"].transparent-glass-lightweight .app-card-colorful, +html[data-theme="transparent"].transparent-glass-lightweight .v-theme--transparent .app-card-colorful, +html[data-theme="transparent"].transparent-glass-realtime .app-card-colorful, +html[data-theme="transparent"].transparent-glass-realtime .v-theme--transparent .app-card-colorful { + backdrop-filter: none; +} + // 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层,避免误触发详情打开。 .app-card-top-action { z-index: 2; diff --git a/src/styles/themes/transparent.scss b/src/styles/themes/transparent.scss index 8a1c6c66..f6fb9026 100644 --- a/src/styles/themes/transparent.scss +++ b/src/styles/themes/transparent.scss @@ -165,3 +165,122 @@ html[data-theme="transparent"] { background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)); } } + +html[data-theme="transparent"].transparent-background-blur-disabled { + .global-blur-layer, + .background-image.active { + backdrop-filter: none; + filter: none; + transform: none; + } +} + +html[data-theme="transparent"].transparent-glass-lightweight { + .global-blur-layer { + display: none !important; + backdrop-filter: none !important; + } + + .layout-vertical-nav, + .v-toolbar, + .v-footer, + .theme-customizer-panel-host, + .theme-customizer-panel, + .agent-assistant-panel, + .agent-assistant-fab__bubble, + .nav-button, + .layout-navbar { + backdrop-filter: blur(var(--transparent-blur)); + } + + .layout-vertical-nav, + .theme-customizer-panel-host, + .agent-assistant-panel { + backdrop-filter: blur(var(--transparent-blur-heavy)); + } + + .v-list, + .v-card:not(.no-blur, .bg-primary), + .v-sheet, + .v-expansion-panel { + backdrop-filter: none; + } + + .v-card:not(.no-blur, .bg-primary), + .v-sheet, + .v-expansion-panel, + .v-list { + background-color: rgba(var(--v-theme-surface), var(--transparent-opacity)); + } + + .v-overlay__content, + .v-overlay__content .v-card:not(.bg-primary), + .v-overlay__content .v-list { + backdrop-filter: blur(var(--transparent-blur)); + } + + .v-overlay__content .v-card:not(.bg-primary), + .v-overlay__content .v-list { + background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)); + } + + .app-card-colorful { + backdrop-filter: none; + } +} + +html[data-theme="transparent"].transparent-glass-realtime { + .background-image.active { + filter: none; + transform: none; + } + + .v-list, + .v-card:not(.no-blur, .bg-primary), + .v-sheet, + .v-expansion-panel { + backdrop-filter: none; + } + + .layout-vertical-nav, + .v-toolbar, + .v-footer, + .theme-customizer-panel-host, + .theme-customizer-panel, + .agent-assistant-panel, + .agent-assistant-fab__bubble, + .nav-button, + .layout-navbar, + .v-overlay__content, + .v-overlay__content .v-card:not(.bg-primary), + .v-overlay__content .v-list { + backdrop-filter: blur(var(--transparent-blur)); + } + + .layout-vertical-nav, + .theme-customizer-panel-host, + .agent-assistant-panel { + backdrop-filter: blur(var(--transparent-blur-heavy)); + } +} + +html[data-theme="transparent"].transparent-blur-disabled { + .layout-vertical-nav, + .v-list, + .v-card:not(.no-blur), + .v-toolbar, + .v-footer, + .v-sheet, + .v-expansion-panel, + .v-overlay__content, + .v-overlay__content .v-card:not(.bg-primary), + .v-overlay__content .v-list, + .theme-customizer-panel-host, + .theme-customizer-panel, + .agent-assistant-panel, + .agent-assistant-fab__bubble, + .nav-button, + .layout-navbar { + backdrop-filter: none !important; + } +} diff --git a/src/views/discover/MediaCardSlideView.vue b/src/views/discover/MediaCardSlideView.vue index 49bfa91d..38c49b80 100644 --- a/src/views/discover/MediaCardSlideView.vue +++ b/src/views/discover/MediaCardSlideView.vue @@ -26,6 +26,7 @@ provide('rankingPropsKey', reactive({ ...props })) const componentLoaded = ref(false) // 是否已尝试加载 const hasTriedLoading = ref(false) +const loadingStarted = ref(false) // 使用 shallowRef 避免横向卡片区的大数组深层代理 const dataList = shallowRef([]) @@ -36,6 +37,8 @@ const containerRef = ref(null) // 获取订阅列表数据 async function fetchData() { try { + if (loadingStarted.value) return + loadingStarted.value = true if (!props.apipath) return dataList.value = await api.get(props.apipath) if (dataList.value.length > 0) { @@ -47,10 +50,29 @@ async function fetchData() { console.error(error) componentLoaded.value = true } finally { + loadingStarted.value = false hasTriedLoading.value = true } } +function isNearViewport(element: HTMLElement, rootMargin = 300) { + const rect = element.getBoundingClientRect() + const viewportHeight = window.innerHeight || document.documentElement.clientHeight + + return rect.bottom >= -rootMargin && rect.top <= viewportHeight + rootMargin +} + +/** IntersectionObserver 未及时回调时,首屏附近内容仍需主动发起数据请求。 */ +function loadIfNearViewport() { + const element = containerRef.value + if (!element || loadingStarted.value || hasTriedLoading.value) return + + if (isNearViewport(element)) { + fetchData() + stop() + } +} + // 使用 IntersectionObserver 实现懒加载 const { stop } = useIntersectionObserver( containerRef, @@ -65,7 +87,13 @@ const { stop } = useIntersectionObserver( }, ) +onMounted(() => { + requestAnimationFrame(loadIfNearViewport) + window.setTimeout(loadIfNearViewport, 600) +}) + onActivated(() => { + loadIfNearViewport() if (dataList.value.length == 0 && hasTriedLoading.value) { fetchData() }