feat(pwa): 完善 iOS 启动与主题恢复
@@ -1,24 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-launch-loading="true" style="
|
||||
<!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-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);
|
||||
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="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" />
|
||||
@@ -40,16 +45,218 @@
|
||||
<!-- 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" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.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" />
|
||||
|
||||
<!-- iOS Safari 防止自动识别 -->
|
||||
<meta name="apple-mobile-web-app-orientations" content="portrait" />
|
||||
|
||||
<!-- Android Chrome PWA 优化 -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
@@ -67,9 +274,7 @@
|
||||
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
|
||||
<!-- 屏幕方向锁定 -->
|
||||
<meta name="screen-orientation" content="portrait" />
|
||||
<meta name="x5-orientation" content="portrait" />
|
||||
<!-- 不锁定屏幕方向,平板和桌面 PWA 需要支持横屏布局。 -->
|
||||
<meta name="x5-fullscreen" content="true" />
|
||||
<meta name="x5-page-mode" content="app" />
|
||||
|
||||
@@ -99,29 +304,36 @@
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
background: var(--initial-loader-bg, #0e1116);
|
||||
background-color: var(--initial-loader-bg, #0e1116);
|
||||
color-scheme: var(--initial-color-scheme, dark);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"],
|
||||
html[data-launch-loading="true"] body {
|
||||
/* 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 {
|
||||
html[data-launch-loading='true'] body {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] #app {
|
||||
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);
|
||||
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 {
|
||||
html[data-launch-loading='true'] .footer-nav-container {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
visibility: hidden !important;
|
||||
@@ -133,25 +345,27 @@
|
||||
z-index: 99999;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
background: var(--initial-loader-bg, #0e1116);
|
||||
background-color: var(--initial-loader-bg, #0e1116);
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
position: relative;
|
||||
block-size: var(--initial-loader-height, 100svh);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
transition: opacity 0.12s ease-out, transform 0.12s ease-out;
|
||||
padding:
|
||||
calc(env(safe-area-inset-top, 0px) + 24px)
|
||||
24px
|
||||
calc(env(safe-area-inset-bottom, 0px) + 48px);
|
||||
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;
|
||||
@@ -171,7 +385,9 @@
|
||||
display: block;
|
||||
block-size: auto;
|
||||
inline-size: 100%;
|
||||
opacity: 0;
|
||||
/* The unthemed SVG is the critical first-frame fallback. */
|
||||
opacity: 1;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
|
||||
.loading-logo img[data-theme-ready='true'] {
|
||||
@@ -179,6 +395,9 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -255,6 +474,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loading-shell,
|
||||
.loading-logo img {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
|
||||
.loading .effects {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超时通知样式 */
|
||||
#loading-timeout {
|
||||
position: absolute;
|
||||
@@ -278,11 +508,11 @@
|
||||
}
|
||||
|
||||
#timeout-btn {
|
||||
color: var(--initial-loader-color, #8D51F9);
|
||||
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);
|
||||
border-bottom: 1px solid var(--initial-loader-color, #8d51f9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -329,6 +559,19 @@
|
||||
},
|
||||
}
|
||||
|
||||
// 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'
|
||||
@@ -337,7 +580,7 @@
|
||||
// 将启动阶段的主题偏好解析为实际主题。
|
||||
function resolveLaunchTheme(themePreference) {
|
||||
if (themePreference === 'auto') {
|
||||
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
return getCachedAutoLaunchTheme() || (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
if (themePreference === 'default') {
|
||||
@@ -532,12 +775,13 @@
|
||||
|
||||
try {
|
||||
const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null')
|
||||
if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string') applyThemedLogoUrl(cachedLogo.url)
|
||||
if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string')
|
||||
applyThemedLogoUrl(cachedLogo.url)
|
||||
} catch {
|
||||
// 缓存异常不影响根据品牌源文件重新生成主题标识。
|
||||
}
|
||||
|
||||
logoSvgSourcePromise ||= fetch('/logo.svg').then(response => {
|
||||
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()
|
||||
})
|
||||
@@ -571,8 +815,9 @@
|
||||
const colorScheme = getLaunchColorScheme(resolvedLaunchTheme)
|
||||
const palette = launchThemePalettes[resolvedLaunchTheme] || launchThemePalettes.light
|
||||
|
||||
// auto 模式下系统明暗可能已变化,不能复用旧的启动背景缓存。
|
||||
const storedLoaderColor = themePreference === 'auto' ? null : getLocalStorageValue('materio-initial-loader-bg')
|
||||
// 自动主题恢复时复用最近一次已确认的底色,避免 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
|
||||
|
||||
@@ -604,10 +849,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。
|
||||
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 viewport 时造成首帧跳动。
|
||||
function syncInitialViewport(force) {
|
||||
const viewport = window.visualViewport
|
||||
const nextHeight = Math.round(viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0)
|
||||
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',
|
||||
@@ -629,6 +876,7 @@
|
||||
// 应用主题色彩
|
||||
applyLaunchThemeChrome()
|
||||
syncInitialViewport(true)
|
||||
document.documentElement.dataset.launchStartedAt = String(performance.now())
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
applyLaunchThemeChrome()
|
||||
@@ -668,7 +916,8 @@
|
||||
// 安全区域适配
|
||||
function updateSafeArea() {
|
||||
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
|
||||
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)',
|
||||
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
'env(safe-area-inset-bottom)',
|
||||
)
|
||||
|
||||
if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
|
||||
@@ -702,54 +951,54 @@
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.replace(url.pathname + url.search + url.hash)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
const timeoutEl = document.getElementById('loading-timeout');
|
||||
const timeoutEl = document.getElementById('loading-timeout')
|
||||
if (timeoutEl) {
|
||||
// 适配多语言
|
||||
const lang = navigator.language || 'zh-CN';
|
||||
const lang = navigator.language || 'zh-CN'
|
||||
const messages = {
|
||||
'zh-CN': {
|
||||
text: '页面加载似乎遇到了阻碍,请尝试',
|
||||
btn: '清除缓存'
|
||||
btn: '清除缓存',
|
||||
},
|
||||
'zh-TW': {
|
||||
text: '頁面載入似乎遇到了阻礙,請嘗試',
|
||||
btn: '清除快取'
|
||||
btn: '清除快取',
|
||||
},
|
||||
'en-US': {
|
||||
text: 'Page loading seems to be blocked, please try',
|
||||
btn: 'Clear Cache'
|
||||
btn: 'Clear Cache',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑
|
||||
let msg = messages['zh-CN'];
|
||||
let msg = messages['zh-CN']
|
||||
if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {
|
||||
msg = messages['zh-TW'];
|
||||
msg = messages['zh-TW']
|
||||
} else if (lang.startsWith('en')) {
|
||||
msg = messages['en-US'];
|
||||
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;
|
||||
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';
|
||||
timeoutEl.innerHTML = ''
|
||||
timeoutEl.appendChild(textNode)
|
||||
timeoutEl.appendChild(btnLink)
|
||||
timeoutEl.style.display = 'block'
|
||||
}
|
||||
}, 15000); // 15秒后显示超时提示
|
||||
}, 15000) // 15秒后显示超时提示
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
|
||||
<div id="loading-bg">
|
||||
<div id="loading-bg" role="status" aria-label="正在加载 MoviePilot" aria-live="polite">
|
||||
<div class="loading-shell">
|
||||
<div class="loading-main">
|
||||
<div class="loading-logo">
|
||||
@@ -771,5 +1020,4 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"dev:pwa": "vite --host --port 5174",
|
||||
"prebuild": "npm run build:icons",
|
||||
"prebuild": "npm run build:icons && npm run generate:pwa-splash",
|
||||
"build": "vite build",
|
||||
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
@@ -131,6 +132,7 @@
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-html": "^1.5.0",
|
||||
"prettier": "^3.9.5",
|
||||
"sharp": "^0.33.5",
|
||||
"stylelint": "^16.13.2",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,68 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
import appleSplashSpecs from './pwa-splash-specs.json' with { type: 'json' }
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const logoPath = path.join(projectRoot, 'public', 'logo.svg')
|
||||
const outputDirectory = path.join(projectRoot, 'public', 'splash')
|
||||
const background = '#0E1116'
|
||||
|
||||
async function createSplash(width, height, scaleFactor, outputPath, format) {
|
||||
// Match the DOM loader's `min(160px, 36vw)` in CSS pixels, then convert it
|
||||
// to physical pixels for the selected Apple launch image.
|
||||
const logoSize = Math.round(Math.min(160, (width / scaleFactor) * 0.36) * scaleFactor)
|
||||
const logo = await sharp(logoPath).resize(logoSize, logoSize, { fit: 'contain' }).png().toBuffer()
|
||||
const image = sharp({
|
||||
create: {
|
||||
width,
|
||||
height,
|
||||
channels: 4,
|
||||
background,
|
||||
},
|
||||
}).composite([
|
||||
{
|
||||
input: logo,
|
||||
left: Math.round((width - logoSize) / 2),
|
||||
top: Math.round((height - logoSize) / 2),
|
||||
},
|
||||
])
|
||||
|
||||
if (format === 'png') {
|
||||
await image.png({ compressionLevel: 9 }).toFile(outputPath)
|
||||
return
|
||||
}
|
||||
|
||||
await image.flatten({ background }).jpeg({ quality: 88, progressive: true }).toFile(outputPath)
|
||||
}
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true })
|
||||
|
||||
for (const { width: portraitWidth, height: portraitHeight, scaleFactor } of appleSplashSpecs) {
|
||||
const landscapeWidth = portraitHeight
|
||||
const landscapeHeight = portraitWidth
|
||||
|
||||
await createSplash(
|
||||
portraitWidth,
|
||||
portraitHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${portraitWidth}-${portraitHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
await createSplash(
|
||||
landscapeWidth,
|
||||
landscapeHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${landscapeWidth}-${landscapeHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the previous fallback filename for older deployments and bookmarked
|
||||
// entries that may still reference it. Its palette matches the new assets.
|
||||
await createSplash(750, 1334, 2, path.join(outputDirectory, 'apple-splash.png'), 'png')
|
||||
|
||||
console.log(
|
||||
`Generated ${appleSplashSpecs.length * 2 + 1} PWA splash assets in ${path.relative(projectRoot, outputDirectory)}`,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "width": 2048, "height": 2732, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2388, "scaleFactor": 2 },
|
||||
{ "width": 1536, "height": 2048, "scaleFactor": 2 },
|
||||
{ "width": 1640, "height": 2360, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2224, "scaleFactor": 2 },
|
||||
{ "width": 1620, "height": 2160, "scaleFactor": 2 },
|
||||
{ "width": 1488, "height": 2266, "scaleFactor": 2 },
|
||||
{ "width": 1320, "height": 2868, "scaleFactor": 3 },
|
||||
{ "width": 1206, "height": 2622, "scaleFactor": 3 },
|
||||
{ "width": 1260, "height": 2736, "scaleFactor": 3 },
|
||||
{ "width": 1290, "height": 2796, "scaleFactor": 3 },
|
||||
{ "width": 1179, "height": 2556, "scaleFactor": 3 },
|
||||
{ "width": 1170, "height": 2532, "scaleFactor": 3 },
|
||||
{ "width": 1284, "height": 2778, "scaleFactor": 3 },
|
||||
{ "width": 1125, "height": 2436, "scaleFactor": 3 },
|
||||
{ "width": 1242, "height": 2688, "scaleFactor": 3 },
|
||||
{ "width": 828, "height": 1792, "scaleFactor": 2 },
|
||||
{ "width": 1242, "height": 2208, "scaleFactor": 3 },
|
||||
{ "width": 750, "height": 1334, "scaleFactor": 2 },
|
||||
{ "width": 640, "height": 1136, "scaleFactor": 2 }
|
||||
]
|
||||
@@ -3,4 +3,9 @@ export function saveLocalTheme(name: string, theme: any) {
|
||||
localStorage.setItem('theme', name)
|
||||
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
||||
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
||||
|
||||
// 自动主题下次恢复时需要一个稳定的首帧明暗结果,避免媒体查询短暂返回浅色。
|
||||
if (name === 'auto') {
|
||||
localStorage.setItem('materio-initial-resolved-theme', theme.current.value.dark ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +51,58 @@ import {
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
const LAUNCH_MIN_VISIBLE_MS = 320
|
||||
const LAUNCH_MAX_WAIT_MS = 1200
|
||||
const LAUNCH_EXIT_DURATION_MS = 180
|
||||
|
||||
function getLaunchNow() {
|
||||
return globalThis.performance?.now?.() ?? Date.now()
|
||||
}
|
||||
|
||||
const launchStartedAt = Number.parseFloat(document.documentElement.dataset.launchStartedAt || '') || getLaunchNow()
|
||||
|
||||
function getRemainingLaunchBudget() {
|
||||
return Math.max(0, LAUNCH_MAX_WAIT_MS - (getLaunchNow() - launchStartedAt))
|
||||
}
|
||||
|
||||
async function waitForLaunchTask(task: Promise<unknown>, timeoutMs: number, label: string) {
|
||||
if (timeoutMs <= 0) return
|
||||
|
||||
await Promise.race([
|
||||
task.catch(error => {
|
||||
console.warn(`[Launch] ${label} failed`, error)
|
||||
}),
|
||||
new Promise<void>(resolve => window.setTimeout(resolve, timeoutMs)),
|
||||
])
|
||||
}
|
||||
|
||||
async function waitForMinimumLaunchVisibility() {
|
||||
const remaining = LAUNCH_MIN_VISIBLE_MS - (getLaunchNow() - launchStartedAt)
|
||||
if (remaining > 0) {
|
||||
await new Promise<void>(resolve => window.setTimeout(resolve, remaining))
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedAutoResolvedTheme() {
|
||||
const cachedTheme = localStorage.getItem('materio-initial-resolved-theme')
|
||||
|
||||
return cachedTheme === 'dark' || cachedTheme === 'light' ? cachedTheme : null
|
||||
}
|
||||
|
||||
function resolveInitialThemeName(themePreference: string) {
|
||||
if (themePreference === 'auto') {
|
||||
return getCachedAutoResolvedTheme() || resolveThemeName(themePreference)
|
||||
}
|
||||
|
||||
return resolveThemeName(themePreference)
|
||||
}
|
||||
|
||||
// 生效主题
|
||||
const vuetifyTheme = useTheme()
|
||||
const { global: globalTheme } = vuetifyTheme
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
globalTheme.name.value = resolveThemeName(themeValue)
|
||||
let resumeThemeSyncTimer: number | null = null
|
||||
globalTheme.name.value = resolveInitialThemeName(themeValue)
|
||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||
|
||||
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
||||
@@ -397,10 +443,18 @@ function updateHtmlThemeAttribute(themeName: string) {
|
||||
}
|
||||
|
||||
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
|
||||
function syncThemePreferenceFromStorage() {
|
||||
function syncThemePreferenceFromStorage(preferCachedAuto = false) {
|
||||
if (resumeThemeSyncTimer !== null) {
|
||||
window.clearTimeout(resumeThemeSyncTimer)
|
||||
resumeThemeSyncTimer = null
|
||||
}
|
||||
|
||||
themeValue = localStorage.getItem('theme') || 'auto'
|
||||
|
||||
const resolvedTheme = resolveThemeName(themeValue)
|
||||
const resolvedTheme =
|
||||
themeValue === 'auto' && preferCachedAuto
|
||||
? getCachedAutoResolvedTheme() || resolveThemeName(themeValue)
|
||||
: resolveThemeName(themeValue)
|
||||
if (globalTheme.name.value !== resolvedTheme) {
|
||||
globalTheme.name.value = resolvedTheme
|
||||
}
|
||||
@@ -411,13 +465,20 @@ function syncThemePreferenceFromStorage() {
|
||||
|
||||
// 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。
|
||||
void themeManager
|
||||
.setTheme(themeValue)
|
||||
.setTheme(themeValue === 'auto' ? resolvedTheme : themeValue)
|
||||
.then(() => {
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('同步主题管理器失败:', error)
|
||||
})
|
||||
|
||||
if (preferCachedAuto && themeValue === 'auto') {
|
||||
resumeThemeSyncTimer = window.setTimeout(() => {
|
||||
resumeThemeSyncTimer = null
|
||||
syncThemePreferenceFromStorage()
|
||||
}, 180)
|
||||
}
|
||||
}
|
||||
|
||||
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
|
||||
@@ -430,7 +491,7 @@ function handleSystemThemeChange() {
|
||||
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
|
||||
function handleVisibilityThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
syncThemePreferenceFromStorage()
|
||||
syncThemePreferenceFromStorage(true)
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
}
|
||||
}
|
||||
@@ -440,7 +501,7 @@ function handlePageShowThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
}
|
||||
syncThemePreferenceFromStorage()
|
||||
syncThemePreferenceFromStorage(true)
|
||||
}
|
||||
|
||||
// 清理背景图交叉淡入淡出定时器。
|
||||
@@ -724,7 +785,7 @@ async function animateAndRemoveLoader() {
|
||||
document.body.style.removeProperty('overflow')
|
||||
completeLaunchLoading()
|
||||
resolve()
|
||||
}, 120)
|
||||
}, LAUNCH_EXIT_DURATION_MS)
|
||||
})
|
||||
} else {
|
||||
completeLaunchLoading()
|
||||
@@ -737,19 +798,27 @@ async function removeLoadingWithStateCheck() {
|
||||
// 设置各个组件的加载状态
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
||||
|
||||
// 静默检查PWA状态恢复
|
||||
// 静默检查PWA状态恢复,但不能让恢复异常或慢请求挡住应用外壳。
|
||||
const pwaController = (window as any).pwaStateController
|
||||
if (pwaController) {
|
||||
await pwaController.waitForStateRestore()
|
||||
if (pwaController?.waitForStateRestore) {
|
||||
await waitForLaunchTask(
|
||||
Promise.resolve().then(() => pwaController.waitForStateRestore()),
|
||||
getRemainingLaunchBudget(),
|
||||
'PWA state restore',
|
||||
)
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
|
||||
await initializePWA()
|
||||
await initializeAuthenticatedState()
|
||||
await waitForLaunchTask(initializePWA(), getRemainingLaunchBudget(), 'PWA detection')
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
// 用户设置不影响首帧布局,交给应用外壳出现后继续加载。
|
||||
void initializeAuthenticatedState().catch(error => {
|
||||
console.warn('[Launch] Authenticated state initialization failed', error)
|
||||
})
|
||||
|
||||
// 快速缓存命中时至少保留短暂的稳定画面,避免 iOS 只闪过一帧。
|
||||
await waitForMinimumLaunchVisibility()
|
||||
|
||||
// 移除加载界面
|
||||
await animateAndRemoveLoader()
|
||||
@@ -824,7 +893,7 @@ onMounted(async () => {
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
// 初始化主题管理器 - 统一处理主题初始化
|
||||
await themeManager.setTheme(themeValue)
|
||||
await themeManager.setTheme(themeValue === 'auto' ? globalTheme.name.value : themeValue)
|
||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
|
||||
@@ -37,4 +37,29 @@ describe('theme palette', () => {
|
||||
window.removeEventListener('moviepilot-theme-primary-color-change', handleFaviconChange)
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the resolved auto theme for the next PWA resume', () => {
|
||||
applyDocumentThemeChrome('auto', {
|
||||
background: '#0E1116',
|
||||
persistLoaderColors: true,
|
||||
primary: '#6E66ED',
|
||||
resolvedTheme: 'dark',
|
||||
})
|
||||
|
||||
expect(localStorage.getItem('materio-initial-loader-bg')).toBe('#0E1116')
|
||||
expect(localStorage.getItem('materio-initial-resolved-theme')).toBe('dark')
|
||||
})
|
||||
|
||||
it('updates the resolved theme cache when the actual theme is confirmed', () => {
|
||||
localStorage.setItem('materio-initial-resolved-theme', 'dark')
|
||||
|
||||
applyDocumentThemeChrome('light', {
|
||||
background: '#F4F5FA',
|
||||
persistLoaderColors: true,
|
||||
primary: '#8D51F9',
|
||||
resolvedTheme: 'light',
|
||||
})
|
||||
|
||||
expect(localStorage.getItem('materio-initial-resolved-theme')).toBe('light')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,6 +133,7 @@ export function applyDocumentThemeChrome(
|
||||
if (options.persistLoaderColors) {
|
||||
localStorage.setItem('materio-initial-loader-bg', background)
|
||||
localStorage.setItem('materio-initial-loader-color', primary)
|
||||
localStorage.setItem('materio-initial-resolved-theme', resolvedTheme)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import appleSplashSpecs from '../../scripts/pwa-splash-specs.json'
|
||||
|
||||
const projectRoot = process.cwd()
|
||||
const indexHtml = readFileSync(resolve(projectRoot, 'index.html'), 'utf8')
|
||||
const splashDirectory = resolve(projectRoot, 'public/splash')
|
||||
|
||||
describe('PWA 启动屏资源', () => {
|
||||
it('为每个横竖屏资源声明静态 iOS 启动图链接', () => {
|
||||
const splashAssets = readdirSync(splashDirectory)
|
||||
.filter(fileName => fileName.endsWith('.jpg'))
|
||||
.sort()
|
||||
const parsedDocument = new DOMParser().parseFromString(indexHtml, 'text/html')
|
||||
const launchLinks = [...parsedDocument.querySelectorAll<HTMLLinkElement>('link[rel="apple-touch-startup-image"]')]
|
||||
const declaredAssets = launchLinks.map(link => link.getAttribute('href')?.split('/').pop()).sort()
|
||||
|
||||
expect(splashAssets).toHaveLength(40)
|
||||
expect(declaredAssets).toEqual(splashAssets)
|
||||
splashAssets.forEach(fileName => expect(existsSync(resolve(splashDirectory, fileName))).toBe(true))
|
||||
|
||||
appleSplashSpecs.forEach(({ width: portraitWidth, height: portraitHeight, scaleFactor }) => {
|
||||
const deviceWidth = portraitWidth / scaleFactor
|
||||
const deviceHeight = portraitHeight / scaleFactor
|
||||
const portraitMedia = `(device-width: ${deviceWidth}px) and (device-height: ${deviceHeight}px) and (-webkit-device-pixel-ratio: ${scaleFactor}) and (orientation: portrait)`
|
||||
const landscapeMedia = `(device-width: ${deviceWidth}px) and (device-height: ${deviceHeight}px) and (-webkit-device-pixel-ratio: ${scaleFactor}) and (orientation: landscape)`
|
||||
|
||||
expect(
|
||||
launchLinks.some(
|
||||
link =>
|
||||
link.getAttribute('href') === `/splash/apple-splash-${portraitWidth}-${portraitHeight}.jpg` &&
|
||||
link.media === portraitMedia,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
launchLinks.some(
|
||||
link =>
|
||||
link.getAttribute('href') === `/splash/apple-splash-${portraitHeight}-${portraitWidth}.jpg` &&
|
||||
link.media === landscapeMedia,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('保持启动层背景、可见回退和动效降级配置一致', () => {
|
||||
expect(indexHtml.toLowerCase()).toContain('--initial-loader-bg: #0e1116')
|
||||
expect(indexHtml).toContain('prefers-reduced-motion: reduce')
|
||||
expect(indexHtml).toContain('inset: 0;')
|
||||
expect(indexHtml).toContain('inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 48px)')
|
||||
expect(indexHtml).toContain('document.documentElement.dataset.launchStartedAt')
|
||||
expect(indexHtml).toContain('materio-initial-resolved-theme')
|
||||
expect(indexHtml).toContain('getCachedLaunchBackground')
|
||||
expect(indexHtml).toContain('opacity: 1')
|
||||
expect(indexHtml).not.toContain('apple-mobile-web-app-orientations')
|
||||
expect(indexHtml).not.toContain('name="screen-orientation"')
|
||||
expect(indexHtml).not.toContain('name="x5-orientation"')
|
||||
})
|
||||
|
||||
it('让原生启动图和网页启动层共享物理屏幕中心', () => {
|
||||
const generatorSource = readFileSync(resolve(projectRoot, 'scripts/generate-pwa-splash.mjs'), 'utf8')
|
||||
expect(generatorSource).toContain('left: Math.round((width - logoSize) / 2)')
|
||||
expect(generatorSource).toContain('top: Math.round((height - logoSize) / 2)')
|
||||
expect(indexHtml).toContain('Keep the brand mark in the physical screen center')
|
||||
})
|
||||
})
|
||||
@@ -91,7 +91,6 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'scope': './',
|
||||
'display': 'standalone',
|
||||
'display_override': ['window-controls-overlay', 'standalone'],
|
||||
'orientation': 'portrait-primary',
|
||||
'lang': 'zh-CN',
|
||||
'dir': 'ltr',
|
||||
'categories': ['entertainment', 'multimedia', 'utilities'],
|
||||
|
||||