mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
fix(theme): preserve logo detail across theme colors (#540)
This commit is contained in:
+179
-65
@@ -4,7 +4,7 @@
|
|||||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||||
--safe-area-inset-top: env(safe-area-inset-top);
|
--safe-area-inset-top: env(safe-area-inset-top);
|
||||||
--initial-loader-bg: #0E1116;
|
--initial-loader-bg: #0E1116;
|
||||||
--initial-loader-color: #9155FD;
|
--initial-loader-color: #8D51F9;
|
||||||
--initial-loader-height: 100svh;
|
--initial-loader-height: 100svh;
|
||||||
--initial-loader-width: 100vw;
|
--initial-loader-width: 100vw;
|
||||||
--initial-color-scheme: dark;
|
--initial-color-scheme: dark;
|
||||||
@@ -171,30 +171,11 @@
|
|||||||
display: block;
|
display: block;
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-logo__mark {
|
.loading-logo img[data-theme-ready='true'] {
|
||||||
display: none;
|
opacity: 1;
|
||||||
}
|
|
||||||
|
|
||||||
@supports ((-webkit-mask-image: url('/logo.svg')) or (mask-image: url('/logo.svg'))) {
|
|
||||||
.loading-logo img {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-logo__mark {
|
|
||||||
display: block;
|
|
||||||
background: linear-gradient(
|
|
||||||
145deg,
|
|
||||||
color-mix(in srgb, var(--initial-loader-color) 48%, white) 0%,
|
|
||||||
var(--initial-loader-color) 48%,
|
|
||||||
color-mix(in srgb, var(--initial-loader-color) 72%, black) 100%
|
|
||||||
);
|
|
||||||
block-size: min(160px, 36vw);
|
|
||||||
inline-size: min(160px, 36vw);
|
|
||||||
mask: url('/logo.svg') center / contain no-repeat;
|
|
||||||
-webkit-mask: url('/logo.svg') center / contain no-repeat;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-footer {
|
.loading-footer {
|
||||||
@@ -297,11 +278,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#timeout-btn {
|
#timeout-btn {
|
||||||
color: var(--initial-loader-color, #9155FD);
|
color: var(--initial-loader-color, #8D51F9);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-inline-start: 8px;
|
margin-inline-start: 8px;
|
||||||
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
|
border-bottom: 1px solid var(--initial-loader-color, #8D51F9);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -327,7 +308,7 @@
|
|||||||
const launchThemePalettes = {
|
const launchThemePalettes = {
|
||||||
light: {
|
light: {
|
||||||
background: '#F4F5FA',
|
background: '#F4F5FA',
|
||||||
primary: '#9155FD',
|
primary: '#8D51F9',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
background: '#0E1116',
|
background: '#0E1116',
|
||||||
@@ -335,7 +316,7 @@
|
|||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
background: '#28243D',
|
background: '#28243D',
|
||||||
primary: '#9155FD',
|
primary: '#8D51F9',
|
||||||
},
|
},
|
||||||
transparent: {
|
transparent: {
|
||||||
background: '#1C1C1C',
|
background: '#1C1C1C',
|
||||||
@@ -386,54 +367,188 @@
|
|||||||
document.head.appendChild(meta)
|
document.head.appendChild(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
let faviconSourceImage
|
let logoSvgSourcePromise
|
||||||
let pendingFaviconColor = '#9155FD'
|
let pendingFaviconColor = '#8D51F9'
|
||||||
|
const themeLogoCacheKey = 'moviepilot-themed-logo-cache'
|
||||||
|
|
||||||
function mixFaviconColor(hexColor, target, amount) {
|
const sourceLogoPalette = {
|
||||||
const normalized = hexColor.replace('#', '')
|
'rgb(141,81,249)': 'primary',
|
||||||
const source = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16))
|
'rgb(165,118,255)': 'light',
|
||||||
|
'rgb(211,187,255)': 'highlight',
|
||||||
return `rgb(${source.map((channel, index) => Math.round(channel + (target[index] - channel) * amount)).join(', ')})`
|
'rgb(116,50,223)': 'dark',
|
||||||
|
'rgb(110,38,217)': 'darker',
|
||||||
|
'rgb(104,0,197)': 'deep',
|
||||||
|
'rgb(91,0,197)': 'deepest',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab 图标使用小尺寸位图承载主题色,避免 favicon 内部 SVG 无法继承页面 CSS 变量。
|
const sourceLogoRgb = {
|
||||||
|
primary: [141, 81, 249],
|
||||||
|
light: [165, 118, 255],
|
||||||
|
highlight: [211, 187, 255],
|
||||||
|
dark: [116, 50, 223],
|
||||||
|
darker: [110, 38, 217],
|
||||||
|
deep: [104, 0, 197],
|
||||||
|
deepest: [91, 0, 197],
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampLogoChannel(value, min = 0, max = 1) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoRgbToHsl([red, green, blue]) {
|
||||||
|
const r = red / 255
|
||||||
|
const g = green / 255
|
||||||
|
const b = blue / 255
|
||||||
|
const max = Math.max(r, g, b)
|
||||||
|
const min = Math.min(r, g, b)
|
||||||
|
const delta = max - min
|
||||||
|
const l = (max + min) / 2
|
||||||
|
|
||||||
|
if (delta === 0) return { h: 0, l, s: 0 }
|
||||||
|
|
||||||
|
const s = delta / (1 - Math.abs(2 * l - 1))
|
||||||
|
let h = 0
|
||||||
|
|
||||||
|
if (max === r) h = ((g - b) / delta) % 6
|
||||||
|
else if (max === g) h = (b - r) / delta + 2
|
||||||
|
else h = (r - g) / delta + 4
|
||||||
|
|
||||||
|
return { h: (h * 60 + 360) % 360, l, s }
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoHslToRgb({ h, l, s }) {
|
||||||
|
const chroma = (1 - Math.abs(2 * l - 1)) * s
|
||||||
|
const segment = h / 60
|
||||||
|
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
|
||||||
|
let channels
|
||||||
|
|
||||||
|
if (segment < 1) channels = [chroma, secondary, 0]
|
||||||
|
else if (segment < 2) channels = [secondary, chroma, 0]
|
||||||
|
else if (segment < 3) channels = [0, chroma, secondary]
|
||||||
|
else if (segment < 4) channels = [0, secondary, chroma]
|
||||||
|
else if (segment < 5) channels = [secondary, 0, chroma]
|
||||||
|
else channels = [chroma, 0, secondary]
|
||||||
|
|
||||||
|
const offset = l - chroma / 2
|
||||||
|
const rgb = channels.map(channel => Math.round((channel + offset) * 255))
|
||||||
|
|
||||||
|
return `rgb(${rgb.join(',')})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftLogoTone(color, hueOffset, lightnessOffset, saturationScale = 1) {
|
||||||
|
return logoHslToRgb({
|
||||||
|
h: (color.h + hueOffset + 360) % 360,
|
||||||
|
l: clampLogoChannel(color.l + lightnessOffset, 0.08, 0.92),
|
||||||
|
s: clampLogoChannel(color.s * saturationScale),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLaunchLogoPalette(primaryColor) {
|
||||||
|
const normalized = primaryColor.slice(1)
|
||||||
|
const rgb = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16))
|
||||||
|
const hsl = logoRgbToHsl(rgb)
|
||||||
|
const sourcePrimaryHsl = logoRgbToHsl(sourceLogoRgb.primary)
|
||||||
|
const lightDirection = hsl.l >= 0.78 ? -1 : 1
|
||||||
|
const darkDirection = hsl.l <= 0.22 ? 1 : -1
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
|
||||||
|
if (key === 'primary') return [key, `rgb(${rgb.join(',')})`]
|
||||||
|
|
||||||
|
const sourceHsl = logoRgbToHsl(sourceRgb)
|
||||||
|
const hueOffset = sourceHsl.h - sourcePrimaryHsl.h
|
||||||
|
const sourceLightnessDelta = sourceHsl.l - sourcePrimaryHsl.l
|
||||||
|
const lightnessDelta =
|
||||||
|
Math.abs(sourceLightnessDelta) * (sourceLightnessDelta >= 0 ? lightDirection : darkDirection)
|
||||||
|
const saturationScale = sourcePrimaryHsl.s ? sourceHsl.s / sourcePrimaryHsl.s : 1
|
||||||
|
|
||||||
|
return [key, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThemedLogoDataUrl(svgSource, primaryColor) {
|
||||||
|
const palette = createLaunchLogoPalette(primaryColor)
|
||||||
|
const themedSvg = Object.entries(sourceLogoPalette).reduce(
|
||||||
|
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
|
||||||
|
svgSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(themedSvg)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLoadingLogo(callback) {
|
||||||
|
const loadingLogo = document.querySelector('.loading-logo img')
|
||||||
|
if (loadingLogo) {
|
||||||
|
callback(loadingLogo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState !== 'loading') return
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const nextLoadingLogo = document.querySelector('.loading-logo img')
|
||||||
|
if (!nextLoadingLogo) return
|
||||||
|
|
||||||
|
observer.disconnect()
|
||||||
|
callback(nextLoadingLogo)
|
||||||
|
})
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemedLogoUrl(themedLogoUrl) {
|
||||||
|
const faviconLink = document.querySelector('#theme-favicon')
|
||||||
|
|
||||||
|
faviconLink?.setAttribute('type', 'image/svg+xml')
|
||||||
|
faviconLink?.setAttribute('href', themedLogoUrl)
|
||||||
|
|
||||||
|
withLoadingLogo(loadingLogo => {
|
||||||
|
const revealLogo = () => loadingLogo.setAttribute('data-theme-ready', 'true')
|
||||||
|
loadingLogo.addEventListener('load', revealLogo, { once: true })
|
||||||
|
loadingLogo.setAttribute('src', themedLogoUrl)
|
||||||
|
if (loadingLogo.complete) revealLogo()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealOriginalLoadingLogo() {
|
||||||
|
withLoadingLogo(loadingLogo => loadingLogo.setAttribute('data-theme-ready', 'true'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动层和 Tab 图标共享原始矢量结构,主题切换只替换色阶,不破坏分面和透明高光。
|
||||||
function syncThemeFavicon(primaryColor) {
|
function syncThemeFavicon(primaryColor) {
|
||||||
if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return
|
if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return
|
||||||
|
|
||||||
pendingFaviconColor = primaryColor
|
pendingFaviconColor = primaryColor
|
||||||
|
|
||||||
const render = () => {
|
try {
|
||||||
const faviconLink = document.querySelector('#theme-favicon')
|
const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null')
|
||||||
if (!faviconLink || !faviconSourceImage?.naturalWidth) return
|
if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string') applyThemedLogoUrl(cachedLogo.url)
|
||||||
|
} catch {
|
||||||
const canvas = document.createElement('canvas')
|
// 缓存异常不影响根据品牌源文件重新生成主题标识。
|
||||||
const context = canvas.getContext('2d')
|
|
||||||
if (!context) return
|
|
||||||
|
|
||||||
canvas.width = 64
|
|
||||||
canvas.height = 64
|
|
||||||
context.drawImage(faviconSourceImage, 0, 0, 64, 64)
|
|
||||||
context.globalCompositeOperation = 'source-in'
|
|
||||||
|
|
||||||
const gradient = context.createLinearGradient(10, 6, 54, 58)
|
|
||||||
gradient.addColorStop(0, mixFaviconColor(pendingFaviconColor, [255, 255, 255], 0.38))
|
|
||||||
gradient.addColorStop(0.48, pendingFaviconColor)
|
|
||||||
gradient.addColorStop(1, mixFaviconColor(pendingFaviconColor, [0, 0, 0], 0.28))
|
|
||||||
context.fillStyle = gradient
|
|
||||||
context.fillRect(0, 0, 64, 64)
|
|
||||||
|
|
||||||
faviconLink.setAttribute('type', 'image/png')
|
|
||||||
faviconLink.setAttribute('href', canvas.toDataURL('image/png'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!faviconSourceImage) {
|
logoSvgSourcePromise ||= fetch('/logo.svg').then(response => {
|
||||||
faviconSourceImage = new Image()
|
if (!response.ok) throw new Error(`Logo SVG request failed: ${response.status}`)
|
||||||
faviconSourceImage.onload = render
|
return response.text()
|
||||||
faviconSourceImage.src = '/logo.svg'
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (faviconSourceImage.complete) render()
|
logoSvgSourcePromise
|
||||||
|
.then(svgSource => {
|
||||||
|
if (primaryColor !== pendingFaviconColor) return
|
||||||
|
|
||||||
|
const themedLogoUrl = createThemedLogoDataUrl(svgSource, primaryColor)
|
||||||
|
applyThemedLogoUrl(themedLogoUrl)
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(themeLogoCacheKey, JSON.stringify({ color: primaryColor, url: themedLogoUrl }))
|
||||||
|
} catch {
|
||||||
|
// 存储空间不可用时仍保留当前页面内的主题标识。
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 原始 SVG 始终保留为无网络或解析异常时的可见回退。
|
||||||
|
revealOriginalLoadingLogo()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('moviepilot-theme-primary-color-change', event => {
|
window.addEventListener('moviepilot-theme-primary-color-change', event => {
|
||||||
@@ -630,7 +745,6 @@
|
|||||||
<div class="loading-logo">
|
<div class="loading-logo">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
||||||
<span class="loading-logo__mark" role="img" aria-label="MoviePilot"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="loading-footer">
|
<div class="loading-footer">
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #9155FD;
|
--primary-color: #8D51F9;
|
||||||
--surface-color: #FFFFFF;
|
--surface-color: #FFFFFF;
|
||||||
--text-color: #333333;
|
--text-color: #333333;
|
||||||
--border-color: rgba(0, 0, 0, 0.12);
|
--border-color: rgba(0, 0, 0, 0.12);
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
margin: 0 auto 32px;
|
margin: 0 auto 32px;
|
||||||
background: rgba(145, 85, 253, 0.1);
|
background: rgba(141, 81, 249, 0.1);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: rgba(145, 85, 253, 0.1);
|
background: rgba(141, 81, 249, 0.1);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { nextTick } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { storageRemoteDict } from '@/api/constants'
|
import { storageRemoteDict } from '@/api/constants'
|
||||||
|
|
||||||
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
|
const DEFAULT_DIRECTORY_ACCENT_RGB = '141, 81, 249'
|
||||||
const STORAGE_ACCENT_COLOR_MAP = {
|
const STORAGE_ACCENT_COLOR_MAP = {
|
||||||
local: '#FFB400',
|
local: '#FFB400',
|
||||||
alipan: '#00A7F2',
|
alipan: '#00A7F2',
|
||||||
|
|||||||
@@ -964,7 +964,7 @@ function registerTexture<T extends ThreeTexture>(texture: T) {
|
|||||||
function getThemeColors() {
|
function getThemeColors() {
|
||||||
const T = requireThree()
|
const T = requireThree()
|
||||||
const colors = vuetifyTheme.global.current.value.colors
|
const colors = vuetifyTheme.global.current.value.colors
|
||||||
const primary = new T.Color(colors.primary || '#9155FD')
|
const primary = new T.Color(colors.primary || '#8D51F9')
|
||||||
const surface = new T.Color(colors.surface || colors.background || '#14161F')
|
const surface = new T.Color(colors.surface || colors.background || '#14161F')
|
||||||
const onSurface = new T.Color(colors['on-surface'] || '#FFFFFF')
|
const onSurface = new T.Color(colors['on-surface'] || '#FFFFFF')
|
||||||
const hsl = { h: 0, l: 0, s: 0 }
|
const hsl = { h: 0, l: 0, s: 0 }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
import logoUrl from '@images/logo.svg'
|
import logoUrl from '@images/logo.svg'
|
||||||
|
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -71,7 +72,7 @@ onBeforeUnmount(() => {
|
|||||||
@pointermove="handlePointerMove"
|
@pointermove="handlePointerMove"
|
||||||
@pointerleave="resetPointerResponse"
|
@pointerleave="resetPointerResponse"
|
||||||
>
|
>
|
||||||
<span class="prismatic-logo__base" aria-hidden="true" />
|
<ThemeLogoMark class="prismatic-logo__base" decorative />
|
||||||
<span class="prismatic-logo__spectrum" aria-hidden="true" />
|
<span class="prismatic-logo__spectrum" aria-hidden="true" />
|
||||||
<span class="prismatic-logo__specular" aria-hidden="true" />
|
<span class="prismatic-logo__specular" aria-hidden="true" />
|
||||||
<span class="prismatic-logo__reveal" aria-hidden="true" />
|
<span class="prismatic-logo__reveal" aria-hidden="true" />
|
||||||
@@ -117,7 +118,6 @@ onBeforeUnmount(() => {
|
|||||||
transform: translate3d(0, 8px, -16px) scaleX(1.18);
|
transform: translate3d(0, 8px, -16px) scaleX(1.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prismatic-logo__base,
|
|
||||||
.prismatic-logo__spectrum,
|
.prismatic-logo__spectrum,
|
||||||
.prismatic-logo__specular,
|
.prismatic-logo__specular,
|
||||||
.prismatic-logo__reveal {
|
.prismatic-logo__reveal {
|
||||||
@@ -131,19 +131,19 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prismatic-logo__base {
|
.prismatic-logo__base {
|
||||||
background: linear-gradient(
|
position: absolute;
|
||||||
145deg,
|
display: block;
|
||||||
color-mix(in srgb, rgb(var(--v-theme-primary)) 46%, white) 0%,
|
block-size: calc(100% - 14px);
|
||||||
rgb(var(--v-theme-primary)) 48%,
|
|
||||||
color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, black) 100%
|
|
||||||
);
|
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
|
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
|
||||||
drop-shadow(0 0 10px rgba(var(--v-theme-primary), 0.22));
|
drop-shadow(0 0 10px rgba(var(--v-theme-primary), 0.22));
|
||||||
|
inline-size: calc(100% - 14px);
|
||||||
|
inset: 7px;
|
||||||
|
pointer-events: none;
|
||||||
transform: translateZ(10px);
|
transform: translateZ(10px);
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prismatic-logo__base,
|
|
||||||
.prismatic-logo__spectrum,
|
.prismatic-logo__spectrum,
|
||||||
.prismatic-logo__specular,
|
.prismatic-logo__specular,
|
||||||
.prismatic-logo__reveal {
|
.prismatic-logo__reveal {
|
||||||
|
|||||||
@@ -1,29 +1,50 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import logoUrl from '@images/logo.svg'
|
import logoSvg from '@images/logo.svg?raw'
|
||||||
|
import { applyThemeLogoPalette, createThemeLogoPalette } from '@/utils/themeLogo'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
|
||||||
/** 使用当前 Vuetify 主题主色渲染非 WebGL 场景中的 MoviePilot 标识。 */
|
const props = withDefaults(
|
||||||
const logoMaskStyle = {
|
defineProps<{
|
||||||
'--theme-logo-mask': `url("${logoUrl}")`,
|
/** 装饰模式不重复暴露图像语义,适用于已有外层可访问名称的复合标识。 */
|
||||||
}
|
decorative?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
decorative: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
/** 保留品牌 SVG 的分面与高光结构,只将原始紫色色阶映射到当前主题色家族。 */
|
||||||
|
const themedLogoSvg = computed(() =>
|
||||||
|
applyThemeLogoPalette(logoSvg, createThemeLogoPalette(theme.current.value.colors.primary)),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="theme-logo-mark" :style="logoMaskStyle" role="img" aria-label="MoviePilot" />
|
<span
|
||||||
|
class="theme-logo-mark"
|
||||||
|
:role="props.decorative ? undefined : 'img'"
|
||||||
|
:aria-label="props.decorative ? undefined : 'MoviePilot'"
|
||||||
|
:aria-hidden="props.decorative || undefined"
|
||||||
|
>
|
||||||
|
<span class="theme-logo-mark__svg" v-html="themedLogoSvg" />
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.theme-logo-mark {
|
.theme-logo-mark {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
flex: none;
|
flex: none;
|
||||||
background: linear-gradient(
|
|
||||||
145deg,
|
|
||||||
color-mix(in srgb, rgb(var(--v-theme-primary)) 48%, white) 0%,
|
|
||||||
rgb(var(--v-theme-primary)) 48%,
|
|
||||||
color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, black) 100%
|
|
||||||
);
|
|
||||||
block-size: 3em;
|
block-size: 3em;
|
||||||
inline-size: 3em;
|
inline-size: 3em;
|
||||||
mask: var(--theme-logo-mask) center / contain no-repeat;
|
}
|
||||||
-webkit-mask: var(--theme-logo-mask) center / contain no-repeat;
|
|
||||||
|
.theme-logo-mark__svg,
|
||||||
|
.theme-logo-mark__svg :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
block-size: 100%;
|
||||||
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getDominantColor } from '@/@core/utils/image'
|
import { getDominantColor } from '@/@core/utils/image'
|
||||||
|
|
||||||
const DEFAULT_ACCENT_RGB = '145, 85, 253'
|
const DEFAULT_ACCENT_RGB = '141, 81, 249'
|
||||||
|
|
||||||
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
|
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
|
||||||
function hexToRgbString(hexColor: string) {
|
function hexToRgbString(hexColor: string) {
|
||||||
@@ -13,14 +13,14 @@ function hexToRgbString(hexColor: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */
|
/** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */
|
||||||
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#9155FD') {
|
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#8D51F9') {
|
||||||
const dominantColor = await getDominantColor(image, { fallback })
|
const dominantColor = await getDominantColor(image, { fallback })
|
||||||
|
|
||||||
return hexToRgbString(dominantColor)
|
return hexToRgbString(dominantColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
|
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
|
||||||
export function useCardAccentColor(fallback = '#9155FD') {
|
export function useCardAccentColor(fallback = '#8D51F9') {
|
||||||
const accentRgb = ref(DEFAULT_ACCENT_RGB)
|
const accentRgb = ref(DEFAULT_ACCENT_RGB)
|
||||||
const imageRef = ref<any>()
|
const imageRef = ref<any>()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change
|
|||||||
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
|
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
|
||||||
|
|
||||||
export const themeCustomizerPrimaryColors = [
|
export const themeCustomizerPrimaryColors = [
|
||||||
{ name: 'Purple', value: '#9155FD' },
|
{ name: 'Purple', value: '#8D51F9' },
|
||||||
{ name: 'Indigo', value: '#3F51B5' },
|
{ name: 'Indigo', value: '#3F51B5' },
|
||||||
{ name: 'Blue', value: '#1976D2' },
|
{ name: 'Blue', value: '#1976D2' },
|
||||||
{ name: 'Cyan', value: '#00BCD4' },
|
{ name: 'Cyan', value: '#00BCD4' },
|
||||||
@@ -126,12 +126,13 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
|
|||||||
const fallback = getDefaultThemeCustomizerSettings()
|
const fallback = getDefaultThemeCustomizerSettings()
|
||||||
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
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||||
? (settings.layout as ThemeCustomizerLayout)
|
? (settings.layout as ThemeCustomizerLayout)
|
||||||
: fallback.layout,
|
: fallback.layout,
|
||||||
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
|
primaryColor,
|
||||||
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
||||||
? (radius as ThemeCustomizerRadius)
|
? (radius as ThemeCustomizerRadius)
|
||||||
: fallback.radius,
|
: fallback.radius,
|
||||||
@@ -155,7 +156,6 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
|||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
||||||
const parsed = stored ? JSON.parse(stored) : {}
|
const parsed = stored ? JSON.parse(stored) : {}
|
||||||
|
|
||||||
return normalizeThemeCustomizerSettings({
|
return normalizeThemeCustomizerSettings({
|
||||||
...fallback,
|
...fallback,
|
||||||
...parsed,
|
...parsed,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const theme: VuetifyOptions['theme'] = {
|
|||||||
light: {
|
light: {
|
||||||
dark: false,
|
dark: false,
|
||||||
colors: {
|
colors: {
|
||||||
'primary': '#9155FD',
|
'primary': '#8D51F9',
|
||||||
'secondary': '#8A8D93',
|
'secondary': '#8A8D93',
|
||||||
'on-secondary': '#FFFFFF',
|
'on-secondary': '#FFFFFF',
|
||||||
'success': '#56CA00',
|
'success': '#56CA00',
|
||||||
@@ -107,7 +107,7 @@ const theme: VuetifyOptions['theme'] = {
|
|||||||
purple: {
|
purple: {
|
||||||
dark: true,
|
dark: true,
|
||||||
colors: {
|
colors: {
|
||||||
'primary': '#9155FD',
|
'primary': '#8D51F9',
|
||||||
'secondary': '#8A8D93',
|
'secondary': '#8A8D93',
|
||||||
'on-secondary': '#FFFFFF',
|
'on-secondary': '#FFFFFF',
|
||||||
'success': '#56CA00',
|
'success': '#56CA00',
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import logoSvg from '@images/logo.svg?raw'
|
||||||
|
import { applyThemeLogoPalette, createThemeLogoPalette } from '@/utils/themeLogo'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('theme logo palette', () => {
|
||||||
|
it('reproduces the source artwork when its original primary color is selected', () => {
|
||||||
|
const result = applyThemeLogoPalette(logoSvg, createThemeLogoPalette('#8D51F9'))
|
||||||
|
|
||||||
|
expect(result).toBe(logoSvg)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces every original logo color without flattening its gradient structure', () => {
|
||||||
|
const palette = createThemeLogoPalette('#00BCD4')
|
||||||
|
const result = applyThemeLogoPalette(logoSvg, palette)
|
||||||
|
const originalColors = [
|
||||||
|
'rgb(141,81,249)',
|
||||||
|
'rgb(165,118,255)',
|
||||||
|
'rgb(211,187,255)',
|
||||||
|
'rgb(116,50,223)',
|
||||||
|
'rgb(110,38,217)',
|
||||||
|
'rgb(104,0,197)',
|
||||||
|
'rgb(91,0,197)',
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(result.match(/<(?:linear|radial)Gradient/g)).toHaveLength(6)
|
||||||
|
expect(result.match(/<path/g)).toHaveLength(12)
|
||||||
|
expect(result).toContain('stop-opacity:1')
|
||||||
|
expect(result).toContain(palette.primary)
|
||||||
|
expect(result).toContain(palette.highlight)
|
||||||
|
expect(result).toContain(palette.deepest)
|
||||||
|
originalColors.forEach(color => expect(result).not.toContain(color))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['#000000', '#808080', '#FFFFFF'])('keeps visible facet contrast for neutral theme color %s', primary => {
|
||||||
|
const palette = createThemeLogoPalette(primary)
|
||||||
|
const distinctColors = new Set(Object.values(palette))
|
||||||
|
|
||||||
|
expect(distinctColors.size).toBeGreaterThanOrEqual(5)
|
||||||
|
expect(palette.primary).not.toBe(palette.highlight)
|
||||||
|
expect(palette.primary).not.toBe(palette.deepest)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export interface ThemeLogoPalette {
|
||||||
|
/** 标识主体色,保持与当前主题主色一致。 */
|
||||||
|
primary: string
|
||||||
|
/** 标识迎光面色阶。 */
|
||||||
|
light: string
|
||||||
|
/** 标识高光渐变色阶。 */
|
||||||
|
highlight: string
|
||||||
|
/** 标识第一层背光面色阶。 */
|
||||||
|
dark: string
|
||||||
|
/** 标识第二层背光面色阶。 */
|
||||||
|
darker: string
|
||||||
|
/** 标识内侧深色面色阶。 */
|
||||||
|
deep: string
|
||||||
|
/** 标识最深的内侧面色阶。 */
|
||||||
|
deepest: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HslColor {
|
||||||
|
h: number
|
||||||
|
l: number
|
||||||
|
s: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLogoPalette: Record<string, keyof ThemeLogoPalette> = {
|
||||||
|
'rgb(141,81,249)': 'primary',
|
||||||
|
'rgb(165,118,255)': 'light',
|
||||||
|
'rgb(211,187,255)': 'highlight',
|
||||||
|
'rgb(116,50,223)': 'dark',
|
||||||
|
'rgb(110,38,217)': 'darker',
|
||||||
|
'rgb(104,0,197)': 'deep',
|
||||||
|
'rgb(91,0,197)': 'deepest',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLogoRgb: Record<keyof ThemeLogoPalette, [number, number, number]> = {
|
||||||
|
primary: [141, 81, 249],
|
||||||
|
light: [165, 118, 255],
|
||||||
|
highlight: [211, 187, 255],
|
||||||
|
dark: [116, 50, 223],
|
||||||
|
darker: [110, 38, 217],
|
||||||
|
deep: [104, 0, 197],
|
||||||
|
deepest: [91, 0, 197],
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min = 0, max = 1) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexColor(hexColor: string) {
|
||||||
|
const normalized = hexColor.trim().replace('#', '')
|
||||||
|
if (!/^[\da-f]{6}$/i.test(normalized)) return null
|
||||||
|
|
||||||
|
return [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as [number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsl([red, green, blue]: [number, number, number]): HslColor {
|
||||||
|
const r = red / 255
|
||||||
|
const g = green / 255
|
||||||
|
const b = blue / 255
|
||||||
|
const max = Math.max(r, g, b)
|
||||||
|
const min = Math.min(r, g, b)
|
||||||
|
const delta = max - min
|
||||||
|
const l = (max + min) / 2
|
||||||
|
|
||||||
|
if (delta === 0) return { h: 0, l, s: 0 }
|
||||||
|
|
||||||
|
const s = delta / (1 - Math.abs(2 * l - 1))
|
||||||
|
let h = 0
|
||||||
|
|
||||||
|
if (max === r) h = ((g - b) / delta) % 6
|
||||||
|
else if (max === g) h = (b - r) / delta + 2
|
||||||
|
else h = (r - g) / delta + 4
|
||||||
|
|
||||||
|
return { h: (h * 60 + 360) % 360, l, s }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb({ h, l, s }: HslColor) {
|
||||||
|
const chroma = (1 - Math.abs(2 * l - 1)) * s
|
||||||
|
const segment = h / 60
|
||||||
|
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
|
||||||
|
let channels: [number, number, number]
|
||||||
|
|
||||||
|
if (segment < 1) channels = [chroma, secondary, 0]
|
||||||
|
else if (segment < 2) channels = [secondary, chroma, 0]
|
||||||
|
else if (segment < 3) channels = [0, chroma, secondary]
|
||||||
|
else if (segment < 4) channels = [0, secondary, chroma]
|
||||||
|
else if (segment < 5) channels = [secondary, 0, chroma]
|
||||||
|
else channels = [chroma, 0, secondary]
|
||||||
|
|
||||||
|
const offset = l - chroma / 2
|
||||||
|
const rgb = channels.map(channel => Math.round((channel + offset) * 255))
|
||||||
|
|
||||||
|
return `rgb(${rgb.join(',')})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftLogoTone(color: HslColor, hueOffset: number, lightnessOffset: number, saturationScale = 1) {
|
||||||
|
return hslToRgb({
|
||||||
|
h: (color.h + hueOffset + 360) % 360,
|
||||||
|
l: clamp(color.l + lightnessOffset, 0.08, 0.92),
|
||||||
|
s: clamp(color.s * saturationScale),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从主题主色生成完整的标识明暗色阶。
|
||||||
|
* 接近黑、白的主题色会反向拉开部分色阶,避免分面收敛成同一颜色。
|
||||||
|
*/
|
||||||
|
export function createThemeLogoPalette(primaryColor: string): ThemeLogoPalette {
|
||||||
|
const rgb = parseHexColor(primaryColor) || [141, 81, 249]
|
||||||
|
const hsl = rgbToHsl(rgb)
|
||||||
|
const sourcePrimaryHsl = rgbToHsl(sourceLogoRgb.primary)
|
||||||
|
const lightDirection = hsl.l >= 0.78 ? -1 : 1
|
||||||
|
const darkDirection = hsl.l <= 0.22 ? 1 : -1
|
||||||
|
const palette = Object.fromEntries(
|
||||||
|
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
|
||||||
|
const paletteKey = key as keyof ThemeLogoPalette
|
||||||
|
if (paletteKey === 'primary') return [paletteKey, `rgb(${rgb.join(',')})`]
|
||||||
|
|
||||||
|
const sourceHsl = rgbToHsl(sourceRgb)
|
||||||
|
const hueOffset = sourceHsl.h - sourcePrimaryHsl.h
|
||||||
|
const sourceLightnessDelta = sourceHsl.l - sourcePrimaryHsl.l
|
||||||
|
const lightnessDelta =
|
||||||
|
Math.abs(sourceLightnessDelta) * (sourceLightnessDelta >= 0 ? lightDirection : darkDirection)
|
||||||
|
const saturationScale = sourcePrimaryHsl.s ? sourceHsl.s / sourcePrimaryHsl.s : 1
|
||||||
|
|
||||||
|
return [paletteKey, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
|
||||||
|
}),
|
||||||
|
) as unknown as ThemeLogoPalette
|
||||||
|
|
||||||
|
return palette
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将原始品牌 SVG 的色阶逐层映射到当前主题色家族,保留路径、渐变和透明高光。 */
|
||||||
|
export function applyThemeLogoPalette(svgSource: string, palette: ThemeLogoPalette) {
|
||||||
|
return Object.entries(sourceLogoPalette).reduce(
|
||||||
|
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
|
||||||
|
svgSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ interface ApplyDocumentThemeChromeOptions {
|
|||||||
export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
|
export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
|
||||||
light: {
|
light: {
|
||||||
background: '#F4F5FA',
|
background: '#F4F5FA',
|
||||||
primary: '#9155FD',
|
primary: '#8D51F9',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
background: '#0E1116',
|
background: '#0E1116',
|
||||||
@@ -27,7 +27,7 @@ export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
|
|||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
background: '#28243D',
|
background: '#28243D',
|
||||||
primary: '#9155FD',
|
primary: '#8D51F9',
|
||||||
},
|
},
|
||||||
transparent: {
|
transparent: {
|
||||||
background: '#1C1C1C',
|
background: '#1C1C1C',
|
||||||
|
|||||||
Reference in New Issue
Block a user