Files
MoviePilot-Frontend/index.html
2026-07-29 10:06:14 +08:00

1024 lines
38 KiB
HTML

<!doctype html>
<html
lang="zh-CN"
data-launch-loading="true"
style="
overflow: hidden;
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-top: env(safe-area-inset-top);
--initial-loader-bg: #0e1116;
--initial-loader-color: #8d51f9;
--initial-loader-height: 100svh;
--initial-loader-width: 100vw;
--initial-color-scheme: dark;
background: var(--initial-loader-bg, #0e1116);
background-color: var(--initial-loader-bg, #0e1116);
color-scheme: dark;
"
>
<head>
<title>MoviePilot</title>
<meta charset="UTF-8" />
<!-- 核心viewport设置 - 针对PWA优化 -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
/>
<!-- 防止缩放和选择,提供原生应用体验 -->
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
<!-- 基础信息 -->
<meta name="description" content="MoviePilot - 智能影视媒体库管理工具" />
<meta name="author" content="MoviePilot" />
<meta name="keywords" content="MoviePilot,影视,媒体库,管理" />
<!-- 安全和隐私 -->
<meta name="Robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="no-referrer" />
<!-- PWA - 基础图标 -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="64x64" />
<link rel="icon" type="image/png" href="/logo.png" sizes="192x192" />
<link id="theme-favicon" rel="icon" type="image/svg+xml" href="/logo.svg" sizes="any" />
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
<!--
iOS still matches launch images by the physical device resolution. Keep
these links static in <head>; inserting them after JavaScript starts is too
late for the system launch screen. Assets are generated by
scripts/generate-pwa-splash.mjs.
-->
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2048-2732.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2732-2048.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1668-2388.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2388-1668.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1536-2048.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2048-1536.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1640-2360.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2360-1640.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1668-2224.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2224-1668.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1620-2160.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2160-1620.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1488-2266.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2266-1488.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1320-2868.jpg"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2868-1320.jpg"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1206-2622.jpg"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2622-1206.jpg"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1260-2736.jpg"
media="(device-width: 420px) and (device-height: 912px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2736-1260.jpg"
media="(device-width: 420px) and (device-height: 912px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1290-2796.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2796-1290.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1179-2556.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2556-1179.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1170-2532.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2532-1170.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1284-2778.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2778-1284.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1125-2436.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2436-1125.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1242-2688.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2688-1242.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-828-1792.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1792-828.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1242-2208.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-2208-1242.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-750-1334.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1334-750.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-640-1136.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/splash/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<!-- iOS Safari 全屏模式 -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
<!-- Android Chrome PWA 优化 -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-title" content="MoviePilot" />
<!-- Microsoft Windows PWA -->
<meta name="msapplication-TileColor" content="#0E1116" />
<meta name="msapplication-TileImage" content="/android-chrome-192x192.png" />
<meta name="msapplication-config" content="none" />
<meta name="msapplication-tap-highlight" content="no" />
<meta name="msapplication-navbutton-color" content="#0E1116" />
<!-- 主题色彩 - 适配深色和浅色模式 -->
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
<meta name="color-scheme" content="dark light" />
<!-- 不锁定屏幕方向,平板和桌面 PWA 需要支持横屏布局。 -->
<meta name="x5-fullscreen" content="true" />
<meta name="x5-page-mode" content="app" />
<!-- UC浏览器优化 -->
<meta name="browsermode" content="application" />
<meta name="wap-font-scale" content="no" />
<!-- 360浏览器优化 -->
<meta name="renderer" content="webkit" />
<!-- 触摸优化 -->
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<!-- 缓存控制 -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- DNS预解析和预连接 -->
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
<link rel="dns-prefetch" href="//image.tmdb.org" />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<style>
html,
body {
background: var(--initial-loader-bg, #0e1116);
background-color: var(--initial-loader-bg, #0e1116);
color-scheme: var(--initial-color-scheme, dark);
}
/* Keep a theme-colored surface behind Vuetify while its CSS variables
are being restored after iOS suspends and resumes the WebView. */
#app {
background: var(--initial-loader-bg, #0e1116);
background-color: var(--initial-loader-bg, #0e1116);
}
html[data-launch-loading='true'],
html[data-launch-loading='true'] body {
overflow: hidden;
}
html[data-launch-loading='true'] body {
min-block-size: var(--initial-loader-height, 100svh);
}
html[data-launch-loading='true'] #app {
min-block-size: var(--initial-loader-height, 100svh);
background: var(--initial-loader-bg, #0e1116);
background-color: var(--initial-loader-bg, #0e1116);
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
html[data-launch-loading='true'] .footer-nav-container {
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
}
#loading-bg {
position: fixed;
inset: 0;
z-index: 99999;
display: block;
overflow: hidden;
background: var(--initial-loader-bg, #0e1116);
background-color: var(--initial-loader-bg, #0e1116);
}
.loading-shell {
box-sizing: border-box;
position: relative;
block-size: var(--initial-loader-height, 100svh);
inline-size: 100%;
min-block-size: var(--initial-loader-height, 100svh);
transition:
opacity 0.18s ease-out,
transform 0.18s ease-out;
}
.loading-main {
position: absolute;
/* Keep the brand mark in the physical screen center. The native
iOS launch image cannot distinguish devices that share a media
query but expose different safe-area insets at runtime. */
inset: 0;
display: flex;
align-items: center;
justify-content: center;
min-block-size: 0;
}
.loading-logo {
display: flex;
align-items: center;
justify-content: center;
inline-size: min(160px, 36vw);
transform: translate3d(0, 0, 0);
will-change: transform;
}
.loading-logo img {
display: block;
block-size: auto;
inline-size: 100%;
/* The unthemed SVG is the critical first-frame fallback. */
opacity: 1;
transition: opacity 0.12s ease-out;
}
.loading-logo img[data-theme-ready='true'] {
opacity: 1;
}
.loading-footer {
position: absolute;
inset-inline: 24px;
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 48px);
display: flex;
align-items: center;
justify-content: center;
min-block-size: clamp(72px, 14vh, 120px);
}
.loading-complete .loading-shell,
.loading-complete #loading-timeout {
opacity: 0;
transform: translate3d(0, 6px, 0);
}
.loading {
position: relative;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 46px;
inline-size: 46px;
transition: opacity 0.6s ease;
}
.loading-complete .loading {
opacity: 0;
}
.loading .effect-1,
.loading .effect-2,
.loading .effect-3 {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 100%;
border-inline-start: 3px solid var(--initial-loader-color, #eee);
inline-size: 100%;
}
.loading .effect-1 {
animation: rotate 1s ease infinite;
}
.loading .effect-2 {
animation: rotate-opacity 1s ease infinite 0.1s;
}
.loading .effect-3 {
animation: rotate-opacity 1s ease infinite 0.2s;
}
.loading .effects {
transition: all 0.3s ease;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(1turn);
}
}
@keyframes rotate-opacity {
0% {
opacity: 0.1;
transform: rotate(0deg);
}
100% {
opacity: 1;
transform: rotate(1turn);
}
}
@media (prefers-reduced-motion: reduce) {
.loading-shell,
.loading-logo img {
transition-duration: 0.01ms;
}
.loading .effects {
animation: none;
}
}
/* 超时通知样式 */
#loading-timeout {
position: absolute;
z-index: 2500;
display: none;
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 24px);
inset-inline-start: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 12px 24px;
border-radius: 12px;
font-size: 14px;
font-family: sans-serif;
text-align: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
max-inline-size: calc(100% - 32px);
white-space: normal;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
#timeout-btn {
color: var(--initial-loader-color, #8d51f9);
text-decoration: none;
font-weight: bold;
margin-inline-start: 8px;
border-bottom: 1px solid var(--initial-loader-color, #8d51f9);
}
</style>
<script>
// 检测系统主题是否为深色模式
function checkPrefersColorSchemeIsDark() {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
} catch (e) {
return false
}
}
// 安全读取本地存储,隐私模式或存储不可用时返回空值。
function getLocalStorageValue(key) {
try {
return localStorage.getItem(key)
} catch (e) {
return null
}
}
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
const launchThemePalettes = {
light: {
background: '#F4F5FA',
primary: '#8D51F9',
},
dark: {
background: '#0E1116',
primary: '#6E66ED',
},
purple: {
background: '#28243D',
primary: '#8D51F9',
},
transparent: {
background: '#1C1C1C',
primary: '#A370F7',
},
glass: {
background: '#0B1322',
primary: '#8D51F9',
},
}
// iOS 从后台恢复或重新创建 WebView 时,媒体查询可能先返回浅色,
// 下一帧才恢复真实的系统深色。自动主题优先沿用最近一次已确认的明暗结果,
// 避免恢复首帧先绘制白底再切回深色。
function getCachedAutoLaunchTheme() {
const cachedTheme = getLocalStorageValue('materio-initial-resolved-theme')
return cachedTheme === 'dark' || cachedTheme === 'light' ? cachedTheme : null
}
function getCachedLaunchBackground() {
const cachedBackground = getLocalStorageValue('materio-initial-loader-bg')
return /^#[0-9a-f]{6}$/i.test(cachedBackground || '') ? cachedBackground : null
}
// 读取已保存的主题偏好,缺省时跟随系统。
function getSavedThemePreference() {
return getLocalStorageValue('theme') || 'auto'
}
// 将启动阶段的主题偏好解析为实际主题。
function resolveLaunchTheme(themePreference) {
if (themePreference === 'auto') {
return getCachedAutoLaunchTheme() || (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
}
if (themePreference === 'default') {
return 'light'
}
return launchThemePalettes[themePreference] ? themePreference : 'light'
}
// 返回启动阶段浏览器原生控件应使用的明暗配色。
function getLaunchColorScheme(themeName) {
return ['dark', 'glass', 'purple', 'transparent'].includes(themeName) ? 'dark' : 'light'
}
// 批量同步匹配选择器的 meta 内容。
function setMetaContent(selector, content) {
document.querySelectorAll(selector).forEach(meta => {
meta.setAttribute('content', content)
})
}
// 更新或创建浏览器主题色 meta 标签。
function syncThemeColorMeta(themeColor) {
const metas = document.querySelectorAll('meta[name="theme-color"]')
if (metas.length) {
metas.forEach(meta => {
meta.setAttribute('content', themeColor)
})
return
}
const meta = document.createElement('meta')
meta.name = 'theme-color'
meta.content = themeColor
document.head.appendChild(meta)
}
let logoSvgSourcePromise
let pendingFaviconColor = '#8D51F9'
const themeLogoCacheKey = 'moviepilot-themed-logo-cache'
const sourceLogoPalette = {
'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 = {
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) {
if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return
pendingFaviconColor = primaryColor
try {
const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null')
if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string')
applyThemedLogoUrl(cachedLogo.url)
} catch {
// 缓存异常不影响根据品牌源文件重新生成主题标识。
}
logoSvgSourcePromise ||= fetch(new URL('./logo.svg', document.baseURI).href).then(response => {
if (!response.ok) throw new Error(`Logo SVG request failed: ${response.status}`)
return response.text()
})
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 => {
syncThemeFavicon(event.detail?.color)
})
function applyLaunchThemeChrome() {
const themePreference = getSavedThemePreference()
const resolvedLaunchTheme = resolveLaunchTheme(themePreference)
const colorScheme = getLaunchColorScheme(resolvedLaunchTheme)
const palette = launchThemePalettes[resolvedLaunchTheme] || launchThemePalettes.light
// 自动主题恢复时复用最近一次已确认的底色,避免 iOS 媒体查询短暂返回浅色。
const storedLoaderColor =
themePreference === 'auto' ? getCachedLaunchBackground() : getLocalStorageValue('materio-initial-loader-bg')
const loaderColor = storedLoaderColor || palette.background
const primaryColor = getLocalStorageValue('materio-initial-loader-color') || palette.primary
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
document.documentElement.setAttribute('data-theme', resolvedLaunchTheme)
document.documentElement.setAttribute('data-theme-preference', themePreference)
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
document.documentElement.style.setProperty('--initial-color-scheme', colorScheme)
document.documentElement.style.backgroundColor = loaderColor
document.documentElement.style.colorScheme = colorScheme
if (document.body) {
document.body.setAttribute('data-theme', resolvedLaunchTheme)
document.body.setAttribute('data-theme-preference', themePreference)
document.body.style.backgroundColor = loaderColor
document.body.style.colorScheme = colorScheme
}
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
syncThemeColorMeta(loaderColor)
syncThemeFavicon(primaryColor)
return {
background: loaderColor,
colorScheme,
resolvedLaunchTheme,
themePreference,
}
}
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 viewport 时造成首帧跳动。
function syncInitialViewport(force) {
const viewport = window.visualViewport
const nextHeight = Math.round(
viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0,
)
const nextWidth = Math.round(viewport?.width || window.innerWidth || document.documentElement.clientWidth || 0)
const currentHeight = parseInt(
document.documentElement.style.getPropertyValue('--initial-loader-height') || '0',
10,
)
if (!nextHeight || !nextWidth) {
return
}
if (!force && currentHeight && Math.abs(nextHeight - currentHeight) < 120) {
return
}
document.documentElement.style.setProperty('--initial-loader-height', `${nextHeight}px`)
document.documentElement.style.setProperty('--initial-loader-width', `${nextWidth}px`)
}
// 应用主题色彩
applyLaunchThemeChrome()
syncInitialViewport(true)
document.documentElement.dataset.launchStartedAt = String(performance.now())
document.addEventListener('DOMContentLoaded', () => {
applyLaunchThemeChrome()
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
applyLaunchThemeChrome()
}
})
window.addEventListener('pageshow', () => {
applyLaunchThemeChrome()
})
window.addEventListener('focus', () => {
applyLaunchThemeChrome()
})
window.addEventListener('orientationchange', () => {
window.setTimeout(() => syncInitialViewport(true), 160)
})
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
applyLaunchThemeChrome()
})
} catch (e) {
// 老浏览器不支持监听系统主题变化时,运行时主题管理器仍会继续接管。
}
// 状态栏适配
if (window.navigator.standalone) {
document.documentElement.style.setProperty('--status-bar-height', '20px')
}
// 安全区域适配
function updateSafeArea() {
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue(
'env(safe-area-inset-bottom)',
)
if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
if (safeAreaBottom) document.documentElement.style.setProperty('--safe-area-bottom', safeAreaBottom)
}
updateSafeArea()
window.addEventListener('resize', updateSafeArea)
window.addEventListener('orientationchange', updateSafeArea)
// 清除缓存处理逻辑
window.clearAndReload = async function () {
try {
// 1. 清除所有缓存
if ('caches' in window) {
const cacheNames = await caches.keys()
await Promise.all(cacheNames.map(name => caches.delete(name)))
console.log('[VersionChecker] 已清除所有缓存')
}
// 2. 注销 Service Worker
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map(registration => registration.unregister()))
console.log('[VersionChecker] 已注销所有 Service Worker')
}
} catch (e) {
console.error('[VersionChecker] 清除缓存时出错:', e)
} finally {
// 3. 重载页面
const url = new URL(window.location.href)
url.searchParams.set('_t', Date.now().toString())
window.location.replace(url.pathname + url.search + url.hash)
}
}
setTimeout(function () {
const timeoutEl = document.getElementById('loading-timeout')
if (timeoutEl) {
// 适配多语言
const lang = navigator.language || 'zh-CN'
const messages = {
'zh-CN': {
text: '页面加载似乎遇到了阻碍,请尝试',
btn: '清除缓存',
},
'zh-TW': {
text: '頁面載入似乎遇到了阻礙,請嘗試',
btn: '清除快取',
},
'en-US': {
text: 'Page loading seems to be blocked, please try',
btn: 'Clear Cache',
},
}
// 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑
let msg = messages['zh-CN']
if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {
msg = messages['zh-TW']
} else if (lang.startsWith('en')) {
msg = messages['en-US']
}
const textNode = document.createTextNode(msg.text + ' ')
const btnLink = document.createElement('a')
btnLink.href = 'javascript:void(0)'
btnLink.id = 'timeout-btn'
btnLink.onclick = window.clearAndReload
btnLink.textContent = msg.btn
timeoutEl.innerHTML = ''
timeoutEl.appendChild(textNode)
timeoutEl.appendChild(btnLink)
timeoutEl.style.display = 'block'
}
}, 15000) // 15秒后显示超时提示
</script>
</head>
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
<div id="loading-bg" role="status" aria-label="正在加载 MoviePilot" aria-live="polite">
<div class="loading-shell">
<div class="loading-main">
<div class="loading-logo">
<!-- Logo -->
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
</div>
</div>
<div class="loading-footer">
<div class="loading">
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
</div>
</div>
<!-- 超时提示 - 默认隐藏 -->
<div id="loading-timeout"></div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>