mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16:44 +08:00
feat(glass): unify soft contours and sidebar corner refraction
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Ref } from 'vue'
|
||||
import {
|
||||
createGlassNavbarDisplacementMap,
|
||||
getGlassNavbarOpticalResponse,
|
||||
getGlassSidebarOpticalResponse,
|
||||
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
|
||||
} from '@/utils/glassNavbarRefraction'
|
||||
import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
||||
@@ -214,7 +215,13 @@ function readDisplacementGeometry(surface: NavigationSurface) {
|
||||
const width = Math.max(1, Math.round(bounds.width))
|
||||
|
||||
const radius = Number.isFinite(borderRadius) ? borderRadius : state.defaultGeometry.radius
|
||||
const optics = opticalResponse.value
|
||||
const optics =
|
||||
surface === 'sidebar'
|
||||
? getGlassSidebarOpticalResponse({
|
||||
deformation: settings.value.glassDeformationStrength,
|
||||
translation: settings.value.glassTranslationStrength,
|
||||
})
|
||||
: opticalResponse.value
|
||||
return {
|
||||
height,
|
||||
radius,
|
||||
@@ -241,7 +248,7 @@ async function syncDisplacementMap(surface: NavigationSurface) {
|
||||
try {
|
||||
if (state.cachedGeometry !== geometryKey) {
|
||||
if (state.failedGeometry === geometryKey) return
|
||||
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics })
|
||||
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics, surface })
|
||||
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
||||
state.failedGeometry = geometryKey
|
||||
invalidateDisplacementMap(surface)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue'
|
||||
import { createGlassNavbarDisplacementMap } from '@/utils/glassNavbarRefraction'
|
||||
import { createGlassNavbarDisplacementMap, getGlassSidebarOpticalResponse } from '@/utils/glassNavbarRefraction'
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('@/utils/glassNavbarRefraction', async importOriginal => ({
|
||||
@@ -190,7 +190,13 @@ describe('GlassNavbarRefractionDefs', () => {
|
||||
expect.objectContaining({ width: 1423, height: 64, radius: 16 }),
|
||||
)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ width: 260, height: 800, radius: 0 }),
|
||||
expect.objectContaining({
|
||||
width: 260,
|
||||
height: 800,
|
||||
radius: 0,
|
||||
surface: 'sidebar',
|
||||
optics: getGlassSidebarOpticalResponse({ deformation: 48, translation: 48 }),
|
||||
}),
|
||||
)
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
expectReadyForSidebar(260)
|
||||
|
||||
@@ -4,6 +4,16 @@ import { cwd } from 'node:process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('glass overlay material styles', () => {
|
||||
it('shares the soft contour between content cards and detached navigation', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-shadow)')
|
||||
expect(styles).toContain('box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-navigation-shadow)')
|
||||
expect(styles).toMatch(
|
||||
/&::before\s*\{[\s\S]*?border-radius: inherit;[\s\S]*?box-shadow: var\(--glass-v3-surface-edge\)/u,
|
||||
)
|
||||
})
|
||||
|
||||
it('reserves space below detached desktop navigation and follows the compact theme radius', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8')
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
--glass-v3-navigation-radius: var(--app-field-radius, 16px);
|
||||
--glass-v3-navigation-inset: 8px;
|
||||
--glass-v3-navigation-content-gap: 16px;
|
||||
--glass-v3-navigation-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
--glass-v3-navigation-blur: clamp(
|
||||
0.65px,
|
||||
calc(0.9px + var(--glass-surface-density, 0.62) * 0.6px - var(--glass-background-visibility, 0.58) * 0.25px),
|
||||
@@ -45,6 +46,16 @@
|
||||
}
|
||||
|
||||
body[data-theme='glass'] {
|
||||
.v-card,
|
||||
.layout-navbar,
|
||||
.layout-vertical-nav::before {
|
||||
// 在表面上解析反光强度,卡片 hover 的局部参数不能被根节点预先求值。
|
||||
--glass-v3-surface-edge:
|
||||
inset 1px 1px 3px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.3)),
|
||||
inset -1px -1px 3px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.22)),
|
||||
inset 0 0 5px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.1));
|
||||
}
|
||||
|
||||
// 已有业务类是语义材料的适配入口;海报整图、内部图表和菜单不承载第二层卡片材料。
|
||||
.layout-page-content
|
||||
.v-card:not(
|
||||
@@ -63,12 +74,7 @@
|
||||
.dashboard-grid-content-measure > :first-child > .v-card {
|
||||
border-radius: var(--app-theme-surface-radius, 20px) !important;
|
||||
background: var(--glass-v3-card-background) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, var(--glass-v3-rim)),
|
||||
inset 1px 0 0 rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.6)),
|
||||
inset -1px -2px 2px rgba(0, 0, 0, 0.16),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.25)),
|
||||
var(--glass-v3-shadow) !important;
|
||||
box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-shadow) !important;
|
||||
}
|
||||
|
||||
.layout-page-content
|
||||
@@ -174,12 +180,21 @@
|
||||
|
||||
.layout-vertical-nav {
|
||||
border-radius: var(--glass-v3-navigation-radius);
|
||||
box-shadow: var(--glass-v3-shadow) !important;
|
||||
box-shadow: var(--glass-v3-navigation-shadow) !important;
|
||||
block-size: calc(100% - 2 * var(--glass-v3-navigation-inset));
|
||||
inline-size: calc(var(--glass-v3-nav-reserved-width) - var(--glass-v3-navigation-inset)) !important;
|
||||
inset-block-start: var(--glass-v3-navigation-inset);
|
||||
inset-inline-start: var(--glass-v3-navigation-inset);
|
||||
overflow: clip;
|
||||
|
||||
&::before {
|
||||
// 材质与采样外壳共用圆角,内沿高光必须绕四角延续,不能绘制直边后再裁断。
|
||||
border-radius: inherit;
|
||||
border-inline-end: 0;
|
||||
background: var(--glass-v3-card-background) !important;
|
||||
// 固定导航共用柔和的掠射光,四角厚度由透镜承担,不叠加等宽硬质亮线。
|
||||
box-shadow: var(--glass-v3-surface-edge) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-vertical-nav-collapsed .layout-vertical-nav.hovered {
|
||||
@@ -188,6 +203,7 @@
|
||||
|
||||
.layout-navbar {
|
||||
border-radius: var(--glass-v3-navigation-radius) !important;
|
||||
box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-navigation-shadow) !important;
|
||||
inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 2 * var(--glass-v3-navigation-inset)) !important;
|
||||
inset-block-start: var(--glass-v3-navigation-inset) !important;
|
||||
inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + var(--glass-v3-navigation-inset)) !important;
|
||||
@@ -237,18 +253,6 @@
|
||||
background: var(--glass-v3-card-background) !important;
|
||||
backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||
box-shadow:
|
||||
inset 0 -1px 0 rgba(255, 255, 255, var(--glass-v3-rim)),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.layout-vertical-nav::before {
|
||||
border-inline-end: 0;
|
||||
background: var(--glass-v3-card-background) !important;
|
||||
box-shadow:
|
||||
inset -1px 0 0 rgba(255, 255, 255, var(--glass-v3-rim)),
|
||||
inset -3px 0 4px rgba(255, 255, 255, 0.035),
|
||||
8px 0 24px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,16 +270,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 稳定背板已承担背景吸收,侧栏用明亮散射层呈现厚度,避免再覆盖一层深色渐变。
|
||||
html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass'] {
|
||||
.layout-wrapper[data-shell-mode='desktop'] .layout-vertical-nav::before {
|
||||
background: var(--glass-v3-card-background) !important;
|
||||
box-shadow:
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.32),
|
||||
8px 0 24px rgba(0, 0, 0, 0.07) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 圆角在脱离边缘的首帧采用最终值,真实inset或transform继续使用同一150ms运动节奏。
|
||||
html[data-theme='glass']:is(
|
||||
[data-glass-appearance='clear'],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createGlassNavbarDisplacementField,
|
||||
getGlassNavbarOpticalResponse,
|
||||
getGlassSidebarOpticalResponse,
|
||||
supportsGlassNavbarLiveRefraction,
|
||||
} from '@/utils/glassNavbarRefraction'
|
||||
|
||||
@@ -45,6 +46,16 @@ describe('getGlassNavbarOpticalResponse', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGlassSidebarOpticalResponse', () => {
|
||||
it.each([0, 50, 99, 100])('keeps the same cubic sliders and equal corner axes at %s', strength => {
|
||||
const parameters = Object.freeze({ deformation: strength, translation: strength })
|
||||
const optics = getGlassSidebarOpticalResponse(parameters)
|
||||
expect(optics.horizontalRatio).toBe(optics.verticalRatio)
|
||||
expect(optics.horizontalRatio).toBeLessThanOrEqual(0.3)
|
||||
expect(optics.translationPx).toBe(getGlassNavbarOpticalResponse(parameters).translationPx)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createGlassNavbarDisplacementField', () => {
|
||||
function pixelAt(field: ReturnType<typeof createGlassNavbarDisplacementField>, x: number, y: number) {
|
||||
const offset = (y * field.width + x) * 4
|
||||
@@ -83,13 +94,19 @@ describe('createGlassNavbarDisplacementField', () => {
|
||||
{ width: 252, height: 846, radius: 24 },
|
||||
{ width: 60, height: 846, radius: 8 },
|
||||
{ width: 60, height: 846, radius: 24 },
|
||||
...[60, 252].flatMap(width =>
|
||||
[0, 8, 12, 16, 20, 24].map(radius => ({ width, height: 180, radius, surface: 'sidebar' as const })),
|
||||
),
|
||||
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
||||
for (const deformation of [0, 48, 100])
|
||||
for (const translation of [0, 48, 100])
|
||||
for (const scale of [-22, -34]) {
|
||||
const field = createGlassNavbarDisplacementField({
|
||||
...geometry,
|
||||
optics: getGlassNavbarOpticalResponse({ deformation, translation }),
|
||||
optics:
|
||||
'surface' in geometry
|
||||
? getGlassSidebarOpticalResponse({ deformation, translation })
|
||||
: getGlassNavbarOpticalResponse({ deformation, translation }),
|
||||
})
|
||||
const source = (x: number, y: number) => {
|
||||
const pixel = pixelAt(field, x, y)
|
||||
@@ -123,6 +140,31 @@ describe('createGlassNavbarDisplacementField', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([8, 12, 16, 20, 24])('turns all four sidebar corners with one radial profile at radius %s', radius => {
|
||||
const field = createGlassNavbarDisplacementField({
|
||||
width: 252,
|
||||
height: 180,
|
||||
radius,
|
||||
surface: 'sidebar',
|
||||
optics: getGlassSidebarOpticalResponse({ deformation: 50, translation: 0 }),
|
||||
})
|
||||
for (let y = 0; y < radius; y += 1)
|
||||
for (let x = 0; x < radius; x += 1) {
|
||||
const topLeft = pixelAt(field, x, y)
|
||||
const topRight = pixelAt(field, field.width - 1 - x, y)
|
||||
const bottomLeft = pixelAt(field, x, field.height - 1 - y)
|
||||
const bottomRight = pixelAt(field, field.width - 1 - x, field.height - 1 - y)
|
||||
expect(topLeft[0] + topRight[0]).toBe(256)
|
||||
expect(topLeft[2] + bottomLeft[2]).toBe(256)
|
||||
expect(bottomRight[0]).toBe(topRight[0])
|
||||
expect(bottomRight[2]).toBe(bottomLeft[2])
|
||||
expect(pixelAt(field, y, x)[0]).toBe(topLeft[2])
|
||||
}
|
||||
expect(pixelAt(field, 126, 2)[2]).toBeLessThan(128)
|
||||
expect(pixelAt(field, 126, 177)[2]).toBeGreaterThan(128)
|
||||
expect(pixelAt(field, 126, 90)).toEqual([128, 128, 128, 255])
|
||||
})
|
||||
|
||||
it('keeps a fixed rectangle optically active without substituting a rounded corner', () => {
|
||||
const field = createGlassNavbarDisplacementField({
|
||||
height: 800,
|
||||
|
||||
@@ -16,26 +16,38 @@ export interface GlassNavbarRefractionBrowserIdentity {
|
||||
}
|
||||
|
||||
export interface GlassNavbarDisplacementGeometry {
|
||||
/** 侧栏使用四边等向的窄透镜;省略时保留顶栏的横向阅读保护方案。 */
|
||||
surface?: 'navbar' | 'sidebar'
|
||||
/** 折射表面的实际 CSS 像素高度。 */
|
||||
height: number
|
||||
/** 最终可见外轮廓的圆角半径。 */
|
||||
radius: number
|
||||
/** 折射表面的实际 CSS 像素宽度。 */
|
||||
width: number
|
||||
/** 由当前生效滑杆计算的顶栏光学响应;省略时采用清透自然默认值。 */
|
||||
/** 由当前生效滑杆计算的导航光学响应;省略时采用清透自然默认值。 */
|
||||
optics?: GlassNavbarOpticalResponse
|
||||
}
|
||||
|
||||
/** 顶栏局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */
|
||||
/** 导航局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */
|
||||
export interface GlassNavbarOpticalResponse {
|
||||
/** 横向边缘峰值位移与轮廓带宽之比。 */
|
||||
horizontalRatio: number
|
||||
/** 纵向峰值位移与轮廓带宽之比,严格小于横向以保护字形高度。 */
|
||||
/** 纵向峰值位移与轮廓带宽之比;顶栏弱于横向,侧栏沿四边等向响应。 */
|
||||
verticalRatio: number
|
||||
/** 主体内容统一向右显示的 CSS 像素偏移,外轮廓平缓回零。 */
|
||||
translationPx: number
|
||||
}
|
||||
|
||||
/** 侧栏的阅读区由窄边带保护,圆角处两轴使用相同强度,避免转角时透镜厚度消失。 */
|
||||
export function getGlassSidebarOpticalResponse(
|
||||
parameters: Pick<GlassOpticalParameters, 'deformation' | 'translation'>,
|
||||
): GlassNavbarOpticalResponse {
|
||||
const navbar = getGlassNavbarOpticalResponse(parameters)
|
||||
// 最大位移不超过带宽的 30%,为圆角内侧的取样回落保留单调余量。
|
||||
const ratio = (navbar.horizontalRatio / 0.42) * 0.3
|
||||
return { horizontalRatio: ratio, verticalRatio: ratio, translationPx: navbar.translationPx }
|
||||
}
|
||||
|
||||
/** 导航以低中段可读性为优先,高段保留完整位移预算;不改写共享参数或材质响应。 */
|
||||
export function getGlassNavbarOpticalResponse(
|
||||
parameters: Pick<GlassOpticalParameters, 'deformation' | 'translation'>,
|
||||
@@ -108,7 +120,10 @@ export function createGlassNavbarDisplacementField({
|
||||
height,
|
||||
radius,
|
||||
width,
|
||||
optics = DEFAULT_NAVBAR_OPTICS,
|
||||
surface = 'navbar',
|
||||
optics = surface === 'sidebar'
|
||||
? getGlassSidebarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural'))
|
||||
: DEFAULT_NAVBAR_OPTICS,
|
||||
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
||||
const pixelWidth = normalizePixelSize(width)
|
||||
const pixelHeight = normalizePixelSize(height)
|
||||
@@ -116,7 +131,11 @@ export function createGlassNavbarDisplacementField({
|
||||
const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0
|
||||
// 直角固定表面没有圆角半径可供推导,仍使用受最短边约束的直边带;radius=0 不能被当成无折射。
|
||||
const radiusBand = pixelRadius > 0 ? pixelRadius * 1.5 : REFRACTION_BAND_PX
|
||||
const maximumBand = Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
// 侧栏边带不穿过圆角圆心,四角法线能连续转向且不会在内侧形成聚焦尖点。
|
||||
const maximumBand =
|
||||
surface === 'sidebar'
|
||||
? Math.min(12, pixelRadius || 12, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
: Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
||||
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
@@ -152,7 +171,10 @@ export function createGlassNavbarDisplacementField({
|
||||
|
||||
// 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。
|
||||
const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64)))
|
||||
const verticalRamp = smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
||||
const verticalRamp =
|
||||
surface === 'sidebar'
|
||||
? smoothstep(Math.max(0, Math.min(1, (edgeY - maximumBand) / 64)))
|
||||
: smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
||||
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
||||
|
||||
if (pixelRadius === 0) {
|
||||
@@ -175,7 +197,8 @@ export function createGlassNavbarDisplacementField({
|
||||
|
||||
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
||||
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
||||
const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2
|
||||
// 侧栏的两轴必须共用同一深度剖面,圆角法线旋转时才不会变成扁平或椭圆透镜。
|
||||
const verticalProfile = surface === 'sidebar' ? profile : Math.sin(Math.PI * verticalProgress) ** 2
|
||||
const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||
const gradientX =
|
||||
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
||||
|
||||
Reference in New Issue
Block a user