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 { useDisplay } from 'vuetify'
|
||||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||||
|
import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue'
|
||||||
import {
|
import {
|
||||||
readThemeCustomizerSettings,
|
readThemeCustomizerSettings,
|
||||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||||
@@ -11,6 +12,7 @@ import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBac
|
|||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useShellScrollState } from '@/composables/useShellScrollState'
|
import { useShellScrollState } from '@/composables/useShellScrollState'
|
||||||
import { useFooterDockHeight } from '@/composables/useFooterDockHeight'
|
import { useFooterDockHeight } from '@/composables/useFooterDockHeight'
|
||||||
|
import { supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction'
|
||||||
|
|
||||||
const FLOATING_NAVBAR_INSET_PX = 16
|
const FLOATING_NAVBAR_INSET_PX = 16
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export default defineComponent({
|
|||||||
// App Dock 通过 Teleport 挂载到 body,不参与内容流;将实际高度交给布局用于末尾避让。
|
// App Dock 通过 Teleport 挂载到 body,不参与内容流;将实际高度交给布局用于末尾避让。
|
||||||
const { footerDockHeight } = useFooterDockHeight()
|
const { footerDockHeight } = useFooterDockHeight()
|
||||||
const fixedShellBackplate = useGlassFixedShellBackplate()
|
const fixedShellBackplate = useGlassFixedShellBackplate()
|
||||||
|
const navbarRefractionMode = supportsGlassNavbarLiveRefraction() ? 'chromium' : 'goal1'
|
||||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||||
const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value)
|
const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value)
|
||||||
const isOverlayShell = computed(() => mdAndDown.value && !appMode.value)
|
const isOverlayShell = computed(() => mdAndDown.value && !appMode.value)
|
||||||
@@ -208,6 +211,7 @@ export default defineComponent({
|
|||||||
? 'theme-qualified'
|
? 'theme-qualified'
|
||||||
: 'connected',
|
: 'connected',
|
||||||
'data-shell-scroll-direction': shellScroll.direction.value,
|
'data-shell-scroll-direction': shellScroll.direction.value,
|
||||||
|
'data-glass-navbar-refraction': navbarRefractionMode,
|
||||||
style: {
|
style: {
|
||||||
'--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`,
|
'--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`,
|
||||||
'--shell-floating-navbar-scale-x': floatingNavbarScale.value,
|
'--shell-floating-navbar-scale-x': floatingNavbarScale.value,
|
||||||
@@ -215,6 +219,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null,
|
||||||
fixedShellBackplateNode,
|
fixedShellBackplateNode,
|
||||||
verticalNav,
|
verticalNav,
|
||||||
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
isStandaloneMode: false,
|
isStandaloneMode: false,
|
||||||
isWindowControlsOverlayMode: false,
|
isWindowControlsOverlayMode: false,
|
||||||
mdAndDown: false,
|
mdAndDown: false,
|
||||||
|
navbarRefractionSupported: false,
|
||||||
revision: undefined as { value: number } | undefined,
|
revision: undefined as { value: number } | undefined,
|
||||||
state: 'expanded' as 'expanded' | 'compact' | 'revealed',
|
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', () => ({
|
vi.mock('@/composables/useThemeCustomizer', () => ({
|
||||||
readThemeCustomizerSettings: () => ({ layout: 'vertical' }),
|
readThemeCustomizerSettings: () => ({ layout: 'vertical' }),
|
||||||
THEME_CUSTOMIZER_CHANGE_EVENT: 'moviepilot:theme-customizer-change',
|
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" />' },
|
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', () => ({
|
vi.mock('@layouts/components/VerticalNav.vue', () => ({
|
||||||
default: { template: '<aside data-testid="vertical-nav"><slot /></aside>' },
|
default: { template: '<aside data-testid="vertical-nav"><slot /></aside>' },
|
||||||
}))
|
}))
|
||||||
@@ -143,6 +152,7 @@ describe('VerticalNavLayout shell states', () => {
|
|||||||
mocks.isStandaloneMode = false
|
mocks.isStandaloneMode = false
|
||||||
mocks.isWindowControlsOverlayMode = false
|
mocks.isWindowControlsOverlayMode = false
|
||||||
mocks.mdAndDown = false
|
mocks.mdAndDown = false
|
||||||
|
mocks.navbarRefractionSupported = false
|
||||||
mocks.state = 'expanded'
|
mocks.state = 'expanded'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -177,6 +187,20 @@ describe('VerticalNavLayout shell states', () => {
|
|||||||
expect(revealedWrapper.get('.layout-navbar').attributes('data-shell-navbar-state')).toBe('revealed')
|
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 () => {
|
it('keeps the footer contract stable across App and drawer shells', async () => {
|
||||||
mocks.appMode = true
|
mocks.appMode = true
|
||||||
mocks.mdAndDown = 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\);/,
|
/\[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;
|
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