feat(pwa): 完善 iOS 启动与主题恢复

This commit is contained in:
jxxghp
2026-07-29 10:06:14 +08:00
parent e5339d524b
commit 8a3b99ae51
51 changed files with 1221 additions and 716 deletions
+317 -69
View File
@@ -1,24 +1,29 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-CN" data-launch-loading="true" style=" <html
lang="zh-CN"
data-launch-loading="true"
style="
overflow: hidden; overflow: hidden;
--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: #8D51F9; --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;
background: var(--initial-loader-bg, #0E1116); background: var(--initial-loader-bg, #0e1116);
background-color: var(--initial-loader-bg, #0E1116); background-color: var(--initial-loader-bg, #0e1116);
color-scheme: dark; color-scheme: dark;
"> "
>
<head> <head>
<title>MoviePilot</title> <title>MoviePilot</title>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- 核心viewport设置 - 针对PWA优化 --> <!-- 核心viewport设置 - 针对PWA优化 -->
<meta name="viewport" <meta
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" /> 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="format-detection" content="telephone=no, date=no, email=no, address=no" />
@@ -40,16 +45,218 @@
<!-- iOS Safari PWA 优化 --> <!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <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-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 全屏模式 --> <!-- iOS Safari 全屏模式 -->
<meta name="apple-mobile-web-app-capable" content="yes" /> <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-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="MoviePilot" /> <meta name="apple-mobile-web-app-title" content="MoviePilot" />
<!-- iOS Safari 防止自动识别 -->
<meta name="apple-mobile-web-app-orientations" content="portrait" />
<!-- Android Chrome PWA 优化 --> <!-- Android Chrome PWA 优化 -->
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-status-bar-style" content="black-translucent" /> <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="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<!-- 屏幕方向锁定 --> <!-- 不锁定屏幕方向,平板和桌面 PWA 需要支持横屏布局。 -->
<meta name="screen-orientation" content="portrait" />
<meta name="x5-orientation" content="portrait" />
<meta name="x5-fullscreen" content="true" /> <meta name="x5-fullscreen" content="true" />
<meta name="x5-page-mode" content="app" /> <meta name="x5-page-mode" content="app" />
@@ -99,29 +304,36 @@
<style> <style>
html, html,
body { body {
background: var(--initial-loader-bg, #0E1116); background: var(--initial-loader-bg, #0e1116);
background-color: var(--initial-loader-bg, #0E1116); background-color: var(--initial-loader-bg, #0e1116);
color-scheme: var(--initial-color-scheme, dark); color-scheme: var(--initial-color-scheme, dark);
} }
html[data-launch-loading="true"], /* Keep a theme-colored surface behind Vuetify while its CSS variables
html[data-launch-loading="true"] body { 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; overflow: hidden;
} }
html[data-launch-loading="true"] body { html[data-launch-loading='true'] body {
min-block-size: var(--initial-loader-height, 100svh); 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); min-block-size: var(--initial-loader-height, 100svh);
background: var(--initial-loader-bg, #0E1116); background: var(--initial-loader-bg, #0e1116);
background-color: var(--initial-loader-bg, #0E1116); background-color: var(--initial-loader-bg, #0e1116);
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
html[data-launch-loading="true"] .footer-nav-container { html[data-launch-loading='true'] .footer-nav-container {
opacity: 0 !important; opacity: 0 !important;
pointer-events: none !important; pointer-events: none !important;
visibility: hidden !important; visibility: hidden !important;
@@ -133,25 +345,27 @@
z-index: 99999; z-index: 99999;
display: block; display: block;
overflow: hidden; overflow: hidden;
background: var(--initial-loader-bg, #0E1116); background: var(--initial-loader-bg, #0e1116);
background-color: var(--initial-loader-bg, #0E1116); background-color: var(--initial-loader-bg, #0e1116);
} }
.loading-shell { .loading-shell {
box-sizing: border-box; box-sizing: border-box;
display: grid; position: relative;
grid-template-rows: minmax(0, 1fr) auto;
block-size: var(--initial-loader-height, 100svh); block-size: var(--initial-loader-height, 100svh);
inline-size: 100%; inline-size: 100%;
min-block-size: var(--initial-loader-height, 100svh); min-block-size: var(--initial-loader-height, 100svh);
transition: opacity 0.12s ease-out, transform 0.12s ease-out; transition:
padding: opacity 0.18s ease-out,
calc(env(safe-area-inset-top, 0px) + 24px) transform 0.18s ease-out;
24px
calc(env(safe-area-inset-bottom, 0px) + 48px);
} }
.loading-main { .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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -171,7 +385,9 @@
display: block; display: block;
block-size: auto; block-size: auto;
inline-size: 100%; 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'] { .loading-logo img[data-theme-ready='true'] {
@@ -179,6 +395,9 @@
} }
.loading-footer { .loading-footer {
position: absolute;
inset-inline: 24px;
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 48px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { #loading-timeout {
position: absolute; position: absolute;
@@ -278,11 +508,11 @@
} }
#timeout-btn { #timeout-btn {
color: var(--initial-loader-color, #8D51F9); 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, #8D51F9); border-bottom: 1px solid var(--initial-loader-color, #8d51f9);
} }
</style> </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() { function getSavedThemePreference() {
return getLocalStorageValue('theme') || 'auto' return getLocalStorageValue('theme') || 'auto'
@@ -337,7 +580,7 @@
// 将启动阶段的主题偏好解析为实际主题。 // 将启动阶段的主题偏好解析为实际主题。
function resolveLaunchTheme(themePreference) { function resolveLaunchTheme(themePreference) {
if (themePreference === 'auto') { if (themePreference === 'auto') {
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light' return getCachedAutoLaunchTheme() || (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
} }
if (themePreference === 'default') { if (themePreference === 'default') {
@@ -532,12 +775,13 @@
try { try {
const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null') 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 { } 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}`) if (!response.ok) throw new Error(`Logo SVG request failed: ${response.status}`)
return response.text() return response.text()
}) })
@@ -571,8 +815,9 @@
const colorScheme = getLaunchColorScheme(resolvedLaunchTheme) const colorScheme = getLaunchColorScheme(resolvedLaunchTheme)
const palette = launchThemePalettes[resolvedLaunchTheme] || launchThemePalettes.light const palette = launchThemePalettes[resolvedLaunchTheme] || launchThemePalettes.light
// auto 模式下系统明暗可能已变化,不能复用旧的启动背景缓存 // 自动主题恢复时复用最近一次已确认的底色,避免 iOS 媒体查询短暂返回浅色
const storedLoaderColor = themePreference === 'auto' ? null : getLocalStorageValue('materio-initial-loader-bg') const storedLoaderColor =
themePreference === 'auto' ? getCachedLaunchBackground() : getLocalStorageValue('materio-initial-loader-bg')
const loaderColor = storedLoaderColor || palette.background const loaderColor = storedLoaderColor || palette.background
const primaryColor = getLocalStorageValue('materio-initial-loader-color') || palette.primary const primaryColor = getLocalStorageValue('materio-initial-loader-color') || palette.primary
@@ -604,10 +849,12 @@
} }
} }
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去 // 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 viewport 时造成首帧跳动
function syncInitialViewport(force) { function syncInitialViewport(force) {
const viewport = window.visualViewport 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 nextWidth = Math.round(viewport?.width || window.innerWidth || document.documentElement.clientWidth || 0)
const currentHeight = parseInt( const currentHeight = parseInt(
document.documentElement.style.getPropertyValue('--initial-loader-height') || '0', document.documentElement.style.getPropertyValue('--initial-loader-height') || '0',
@@ -629,6 +876,7 @@
// 应用主题色彩 // 应用主题色彩
applyLaunchThemeChrome() applyLaunchThemeChrome()
syncInitialViewport(true) syncInitialViewport(true)
document.documentElement.dataset.launchStartedAt = String(performance.now())
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
applyLaunchThemeChrome() applyLaunchThemeChrome()
@@ -668,7 +916,8 @@
// 安全区域适配 // 安全区域适配
function updateSafeArea() { function updateSafeArea() {
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)') 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) if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
@@ -702,54 +951,54 @@
url.searchParams.set('_t', Date.now().toString()) url.searchParams.set('_t', Date.now().toString())
window.location.replace(url.pathname + url.search + url.hash) window.location.replace(url.pathname + url.search + url.hash)
} }
}; }
setTimeout(function () { setTimeout(function () {
const timeoutEl = document.getElementById('loading-timeout'); const timeoutEl = document.getElementById('loading-timeout')
if (timeoutEl) { if (timeoutEl) {
// 适配多语言 // 适配多语言
const lang = navigator.language || 'zh-CN'; const lang = navigator.language || 'zh-CN'
const messages = { const messages = {
'zh-CN': { 'zh-CN': {
text: '页面加载似乎遇到了阻碍,请尝试', text: '页面加载似乎遇到了阻碍,请尝试',
btn: '清除缓存' btn: '清除缓存',
}, },
'zh-TW': { 'zh-TW': {
text: '頁面載入似乎遇到了阻礙,請嘗試', text: '頁面載入似乎遇到了阻礙,請嘗試',
btn: '清除快取' btn: '清除快取',
}, },
'en-US': { 'en-US': {
text: 'Page loading seems to be blocked, please try', text: 'Page loading seems to be blocked, please try',
btn: 'Clear Cache' btn: 'Clear Cache',
},
} }
};
// 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑 // 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑
let msg = messages['zh-CN']; let msg = messages['zh-CN']
if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) { if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {
msg = messages['zh-TW']; msg = messages['zh-TW']
} else if (lang.startsWith('en')) { } else if (lang.startsWith('en')) {
msg = messages['en-US']; msg = messages['en-US']
} }
const textNode = document.createTextNode(msg.text + ' '); const textNode = document.createTextNode(msg.text + ' ')
const btnLink = document.createElement('a'); const btnLink = document.createElement('a')
btnLink.href = 'javascript:void(0)'; btnLink.href = 'javascript:void(0)'
btnLink.id = 'timeout-btn'; btnLink.id = 'timeout-btn'
btnLink.onclick = window.clearAndReload; btnLink.onclick = window.clearAndReload
btnLink.textContent = msg.btn; btnLink.textContent = msg.btn
timeoutEl.innerHTML = ''; timeoutEl.innerHTML = ''
timeoutEl.appendChild(textNode); timeoutEl.appendChild(textNode)
timeoutEl.appendChild(btnLink); timeoutEl.appendChild(btnLink)
timeoutEl.style.display = 'block'; timeoutEl.style.display = 'block'
} }
}, 15000); // 15秒后显示超时提示 }, 15000) // 15秒后显示超时提示
</script> </script>
</head> </head>
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch"> <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-shell">
<div class="loading-main"> <div class="loading-main">
<div class="loading-logo"> <div class="loading-logo">
@@ -771,5 +1020,4 @@
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -7,8 +7,9 @@
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"dev:pwa": "vite --host --port 5174", "dev:pwa": "vite --host --port 5174",
"prebuild": "npm run build:icons", "prebuild": "npm run build:icons && npm run generate:pwa-splash",
"build": "vite build", "build": "vite build",
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
"preview": "vite preview --port 5050", "preview": "vite preview --port 5050",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
@@ -131,6 +132,7 @@
"postcss": "^8.5.1", "postcss": "^8.5.1",
"postcss-html": "^1.5.0", "postcss-html": "^1.5.0",
"prettier": "^3.9.5", "prettier": "^3.9.5",
"sharp": "^0.33.5",
"stylelint": "^16.13.2", "stylelint": "^16.13.2",
"stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard-scss": "^14.0.0", "stylelint-config-standard-scss": "^14.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+68
View File
@@ -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)}`,
)
+22
View File
@@ -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 }
]
+5
View File
@@ -3,4 +3,9 @@ export function saveLocalTheme(name: string, theme: any) {
localStorage.setItem('theme', name) localStorage.setItem('theme', name)
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background) localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary) 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')
}
} }
+84 -15
View File
@@ -51,12 +51,58 @@ import {
const LOGIN_WALLPAPER_ROUTE = '/login' const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500 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 vuetifyTheme = useTheme()
const { global: globalTheme } = vuetifyTheme const { global: globalTheme } = vuetifyTheme
let themeValue = localStorage.getItem('theme') || 'auto' let themeValue = localStorage.getItem('theme') || 'auto'
globalTheme.name.value = resolveThemeName(themeValue) let resumeThemeSyncTimer: number | null = null
globalTheme.name.value = resolveInitialThemeName(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme) applyStoredThemeCustomizerAppearance(vuetifyTheme)
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。 // 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
@@ -397,10 +443,18 @@ function updateHtmlThemeAttribute(themeName: string) {
} }
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。 // 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
function syncThemePreferenceFromStorage() { function syncThemePreferenceFromStorage(preferCachedAuto = false) {
if (resumeThemeSyncTimer !== null) {
window.clearTimeout(resumeThemeSyncTimer)
resumeThemeSyncTimer = null
}
themeValue = localStorage.getItem('theme') || 'auto' themeValue = localStorage.getItem('theme') || 'auto'
const resolvedTheme = resolveThemeName(themeValue) const resolvedTheme =
themeValue === 'auto' && preferCachedAuto
? getCachedAutoResolvedTheme() || resolveThemeName(themeValue)
: resolveThemeName(themeValue)
if (globalTheme.name.value !== resolvedTheme) { if (globalTheme.name.value !== resolvedTheme) {
globalTheme.name.value = resolvedTheme globalTheme.name.value = resolvedTheme
} }
@@ -411,13 +465,20 @@ function syncThemePreferenceFromStorage() {
// 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。 // 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。
void themeManager void themeManager
.setTheme(themeValue) .setTheme(themeValue === 'auto' ? resolvedTheme : themeValue)
.then(() => { .then(() => {
updateHtmlThemeAttribute(globalTheme.name.value) updateHtmlThemeAttribute(globalTheme.name.value)
}) })
.catch(error => { .catch(error => {
console.error('同步主题管理器失败:', error) console.error('同步主题管理器失败:', error)
}) })
if (preferCachedAuto && themeValue === 'auto') {
resumeThemeSyncTimer = window.setTimeout(() => {
resumeThemeSyncTimer = null
syncThemePreferenceFromStorage()
}, 180)
}
} }
// 系统配色变化时,在自动主题模式下刷新当前实际主题。 // 系统配色变化时,在自动主题模式下刷新当前实际主题。
@@ -430,7 +491,7 @@ function handleSystemThemeChange() {
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */ /** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
function handleVisibilityThemeSync() { function handleVisibilityThemeSync() {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
syncThemePreferenceFromStorage() syncThemePreferenceFromStorage(true)
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck() if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
} }
} }
@@ -440,7 +501,7 @@ function handlePageShowThemeSync() {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck() if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
} }
syncThemePreferenceFromStorage() syncThemePreferenceFromStorage(true)
} }
// 清理背景图交叉淡入淡出定时器。 // 清理背景图交叉淡入淡出定时器。
@@ -724,7 +785,7 @@ async function animateAndRemoveLoader() {
document.body.style.removeProperty('overflow') document.body.style.removeProperty('overflow')
completeLaunchLoading() completeLaunchLoading()
resolve() resolve()
}, 120) }, LAUNCH_EXIT_DURATION_MS)
}) })
} else { } else {
completeLaunchLoading() completeLaunchLoading()
@@ -737,19 +798,27 @@ async function removeLoadingWithStateCheck() {
// 设置各个组件的加载状态 // 设置各个组件的加载状态
globalLoadingStateManager.setLoadingState('pwa-state', true) globalLoadingStateManager.setLoadingState('pwa-state', true)
// 静默检查PWA状态恢复 // 静默检查PWA状态恢复,但不能让恢复异常或慢请求挡住应用外壳。
const pwaController = (window as any).pwaStateController const pwaController = (window as any).pwaStateController
if (pwaController) { if (pwaController?.waitForStateRestore) {
await pwaController.waitForStateRestore() await waitForLaunchTask(
Promise.resolve().then(() => pwaController.waitForStateRestore()),
getRemainingLaunchBudget(),
'PWA state restore',
)
} }
globalLoadingStateManager.setLoadingState('pwa-state', false) globalLoadingStateManager.setLoadingState('pwa-state', false)
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。 // PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
await initializePWA() await waitForLaunchTask(initializePWA(), getRemainingLaunchBudget(), 'PWA detection')
await initializeAuthenticatedState()
// 等待所有加载完成 // 用户设置不影响首帧布局,交给应用外壳出现后继续加载。
await globalLoadingStateManager.waitForAllComplete() void initializeAuthenticatedState().catch(error => {
console.warn('[Launch] Authenticated state initialization failed', error)
})
// 快速缓存命中时至少保留短暂的稳定画面,避免 iOS 只闪过一帧。
await waitForMinimumLaunchVisibility()
// 移除加载界面 // 移除加载界面
await animateAndRemoveLoader() await animateAndRemoveLoader()
@@ -824,7 +893,7 @@ onMounted(async () => {
updateHtmlThemeAttribute(globalTheme.name.value) updateHtmlThemeAttribute(globalTheme.name.value)
// 初始化主题管理器 - 统一处理主题初始化 // 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue) await themeManager.setTheme(themeValue === 'auto' ? globalTheme.name.value : themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme) applyStoredThemeCustomizerAppearance(vuetifyTheme)
updateHtmlThemeAttribute(globalTheme.name.value) updateHtmlThemeAttribute(globalTheme.name.value)
+25
View File
@@ -37,4 +37,29 @@ describe('theme palette', () => {
window.removeEventListener('moviepilot-theme-primary-color-change', handleFaviconChange) 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')
})
}) })
+1
View File
@@ -133,6 +133,7 @@ export function applyDocumentThemeChrome(
if (options.persistLoaderColors) { if (options.persistLoaderColors) {
localStorage.setItem('materio-initial-loader-bg', background) localStorage.setItem('materio-initial-loader-bg', background)
localStorage.setItem('materio-initial-loader-color', primary) localStorage.setItem('materio-initial-loader-color', primary)
localStorage.setItem('materio-initial-resolved-theme', resolvedTheme)
} }
return { return {
+66
View File
@@ -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')
})
})
-1
View File
@@ -91,7 +91,6 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'scope': './', 'scope': './',
'display': 'standalone', 'display': 'standalone',
'display_override': ['window-controls-overlay', 'standalone'], 'display_override': ['window-controls-overlay', 'standalone'],
'orientation': 'portrait-primary',
'lang': 'zh-CN', 'lang': 'zh-CN',
'dir': 'ltr', 'dir': 'ltr',
'categories': ['entertainment', 'multimedia', 'utilities'], 'categories': ['entertainment', 'multimedia', 'utilities'],