mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 18:26:45 +08:00
feat(theme): prototype live navbar refraction
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||
import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
@@ -11,6 +12,7 @@ import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBac
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useShellScrollState } from '@/composables/useShellScrollState'
|
||||
import { useFooterDockHeight } from '@/composables/useFooterDockHeight'
|
||||
import { supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction'
|
||||
|
||||
const FLOATING_NAVBAR_INSET_PX = 16
|
||||
|
||||
@@ -26,6 +28,7 @@ export default defineComponent({
|
||||
// App Dock 通过 Teleport 挂载到 body,不参与内容流;将实际高度交给布局用于末尾避让。
|
||||
const { footerDockHeight } = useFooterDockHeight()
|
||||
const fixedShellBackplate = useGlassFixedShellBackplate()
|
||||
const navbarRefractionMode = supportsGlassNavbarLiveRefraction() ? 'chromium' : 'goal1'
|
||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||
const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value)
|
||||
const isOverlayShell = computed(() => mdAndDown.value && !appMode.value)
|
||||
@@ -208,6 +211,7 @@ export default defineComponent({
|
||||
? 'theme-qualified'
|
||||
: 'connected',
|
||||
'data-shell-scroll-direction': shellScroll.direction.value,
|
||||
'data-glass-navbar-refraction': navbarRefractionMode,
|
||||
style: {
|
||||
'--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`,
|
||||
'--shell-floating-navbar-scale-x': floatingNavbarScale.value,
|
||||
@@ -215,6 +219,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
[
|
||||
navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null,
|
||||
fixedShellBackplateNode,
|
||||
verticalNav,
|
||||
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
||||
|
||||
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
|
||||
isStandaloneMode: false,
|
||||
isWindowControlsOverlayMode: false,
|
||||
mdAndDown: false,
|
||||
navbarRefractionSupported: false,
|
||||
revision: undefined as { value: number } | undefined,
|
||||
state: 'expanded' as 'expanded' | 'compact' | 'revealed',
|
||||
}))
|
||||
@@ -85,6 +86,10 @@ vi.mock('@/composables/useGlassFixedShellBackplate', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/glassNavbarRefraction', () => ({
|
||||
supportsGlassNavbarLiveRefraction: () => mocks.navbarRefractionSupported,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useThemeCustomizer', () => ({
|
||||
readThemeCustomizerSettings: () => ({ layout: 'vertical' }),
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT: 'moviepilot:theme-customizer-change',
|
||||
@@ -94,6 +99,10 @@ vi.mock('@/components/theme/GlassFixedShellBackplate.vue', () => ({
|
||||
default: { template: '<div data-testid="fixed-shell-backplate" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/theme/GlassNavbarRefractionDefs.vue', () => ({
|
||||
default: { template: '<svg data-testid="navbar-refraction-defs" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@layouts/components/VerticalNav.vue', () => ({
|
||||
default: { template: '<aside data-testid="vertical-nav"><slot /></aside>' },
|
||||
}))
|
||||
@@ -143,6 +152,7 @@ describe('VerticalNavLayout shell states', () => {
|
||||
mocks.isStandaloneMode = false
|
||||
mocks.isWindowControlsOverlayMode = false
|
||||
mocks.mdAndDown = false
|
||||
mocks.navbarRefractionSupported = false
|
||||
mocks.state = 'expanded'
|
||||
})
|
||||
|
||||
@@ -177,6 +187,20 @@ describe('VerticalNavLayout shell states', () => {
|
||||
expect(revealedWrapper.get('.layout-navbar').attributes('data-shell-navbar-state')).toBe('revealed')
|
||||
})
|
||||
|
||||
it('mounts live backdrop definitions only for the verified Chromium path', () => {
|
||||
const goal1Wrapper = mountLayout()
|
||||
|
||||
expect(goal1Wrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('goal1')
|
||||
expect(goal1Wrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(false)
|
||||
goal1Wrapper.unmount()
|
||||
|
||||
mocks.navbarRefractionSupported = true
|
||||
const chromiumWrapper = mountLayout()
|
||||
|
||||
expect(chromiumWrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('chromium')
|
||||
expect(chromiumWrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the footer contract stable across App and drawer shells', async () => {
|
||||
mocks.appMode = true
|
||||
mocks.mdAndDown = true
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts" setup>
|
||||
const displacementMapMarkup = `
|
||||
<svg viewBox="0 0 1200 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="x" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="rgb(24 128 0)" />
|
||||
<stop offset="8%" stop-color="rgb(128 128 0)" />
|
||||
<stop offset="92%" stop-color="rgb(128 128 0)" />
|
||||
<stop offset="100%" stop-color="rgb(232 128 0)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="y" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgb(0 0 28)" />
|
||||
<stop offset="18%" stop-color="rgb(0 0 128)" />
|
||||
<stop offset="82%" stop-color="rgb(0 0 128)" />
|
||||
<stop offset="100%" stop-color="rgb(0 0 228)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="80" rx="20" fill="black" />
|
||||
<rect width="1200" height="80" rx="20" fill="url(#x)" />
|
||||
<rect width="1200" height="80" rx="20" fill="url(#y)" style="mix-blend-mode: screen" />
|
||||
<rect
|
||||
x="10"
|
||||
y="10"
|
||||
width="1180"
|
||||
height="60"
|
||||
rx="14"
|
||||
fill="rgb(128 128 128)"
|
||||
style="filter: blur(8px)"
|
||||
/>
|
||||
</svg>
|
||||
`
|
||||
const displacementMapUrl = `data:image/svg+xml,${encodeURIComponent(displacementMapMarkup)}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="glass-navbar-refraction-defs" width="0" height="0" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<filter
|
||||
id="glass-navbar-live-refraction-balanced"
|
||||
x="-8%"
|
||||
y="-80%"
|
||||
width="116%"
|
||||
height="260%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feImage
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
:href="displacementMapUrl"
|
||||
result="map"
|
||||
/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-22" />
|
||||
</filter>
|
||||
|
||||
<filter
|
||||
id="glass-navbar-live-refraction-high"
|
||||
x="-12%"
|
||||
y="-100%"
|
||||
width="124%"
|
||||
height="300%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feImage
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
:href="displacementMapUrl"
|
||||
result="map"
|
||||
/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-34" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.glass-navbar-refraction-defs {
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -392,4 +392,16 @@ describe('glass overlay material styles', () => {
|
||||
/\[data-glass-appearance='frosted'\]\[data-page-presentation-motion='active'\]\s+\.mp-page-route\s*\{[\s\S]*?opacity:\s*1;[\s\S]*?transform:\s*translate3d\(0,\s*var\(--mp-page-motion-translate-y,\s*0\),\s*0\);/,
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes live navbar refraction to Chromium clear and tinted floating shells', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain("data-glass-navbar-refraction='chromium'")
|
||||
expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')")
|
||||
expect(styles).toContain("url('#glass-navbar-live-refraction-high')")
|
||||
expect(styles).toMatch(
|
||||
/:is\(\[data-glass-appearance='clear'\], \[data-glass-appearance='tinted'\]\)[\s\S]*?\.layout-navbar-floating-eligible\.layout-navbar-away-from-top[\s\S]*?\.layout-navbar/,
|
||||
)
|
||||
expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1651,3 +1651,58 @@ html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-with
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Chromium 直接位移浏览器已合成的下层内容;其他引擎保留稳定的基础顶栏材质。
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: inherit;
|
||||
-webkit-backdrop-filter: blur(1.5px) saturate(118%);
|
||||
backdrop-filter: url('#glass-navbar-live-refraction-balanced') blur(1.5px) saturate(118%);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%),
|
||||
linear-gradient(rgba(7, 14, 25, 0.06), rgba(7, 14, 25, 0.13));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4),
|
||||
inset 0 -1px 0 rgba(4, 10, 20, 0.22),
|
||||
0 12px 32px rgba(3, 7, 18, 0.16);
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.navbar-content-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='glass'][data-glass-quality='high']:is(
|
||||
[data-glass-appearance='clear'],
|
||||
[data-glass-appearance='tinted']
|
||||
)
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar::before {
|
||||
backdrop-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%);
|
||||
}
|
||||
|
||||
html[data-theme='glass'][data-glass-appearance='tinted']
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar::before {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.14), transparent 36%),
|
||||
linear-gradient(rgba(var(--glass-material-accent-rgb), 0.09), rgba(var(--glass-material-accent-rgb), 0.035)),
|
||||
linear-gradient(rgba(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction'
|
||||
|
||||
describe('supportsGlassNavbarLiveRefraction', () => {
|
||||
it('enables the verified Chromium engine path for Chrome and Edge', () => {
|
||||
expect(
|
||||
supportsGlassNavbarLiveRefraction({
|
||||
userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36',
|
||||
userAgentData: {
|
||||
brands: [{ brand: 'Chromium' }, { brand: 'Google Chrome' }],
|
||||
},
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(
|
||||
supportsGlassNavbarLiveRefraction({
|
||||
userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0',
|
||||
userAgentData: {
|
||||
brands: [{ brand: 'Chromium' }, { brand: 'Microsoft Edge' }],
|
||||
},
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Safari', 'Mozilla/5.0 Version/26.4 Safari/605.1.15'],
|
||||
['iOS Chrome', 'Mozilla/5.0 CriOS/140.0.0.0 Mobile/15E148 Safari/604.1'],
|
||||
['Firefox', 'Mozilla/5.0 Firefox/142.0'],
|
||||
])('keeps %s on the stable Goal 1 material', (_browser, userAgent) => {
|
||||
expect(supportsGlassNavbarLiveRefraction({ userAgent })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface GlassNavbarRefractionBrowserIdentity {
|
||||
/** User-Agent Client Hints 暴露的浏览器品牌。 */
|
||||
userAgentData?: {
|
||||
brands?: readonly {
|
||||
brand: string
|
||||
}[]
|
||||
}
|
||||
/** Client Hints 不可用时使用的传统浏览器标识。 */
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
/** 仅在已验证 SVG backdrop 位移的 Chromium 引擎启用实时顶栏折射。 */
|
||||
export function supportsGlassNavbarLiveRefraction(browserIdentity: GlassNavbarRefractionBrowserIdentity = navigator) {
|
||||
const brands = browserIdentity.userAgentData?.brands
|
||||
if (brands?.length) return brands.some(({ brand }) => brand === 'Chromium')
|
||||
|
||||
return /\b(?:Chrome|Chromium)\/\d+/u.test(browserIdentity.userAgent)
|
||||
}
|
||||
Reference in New Issue
Block a user