feat(pwa): 完善 iOS 启动与主题恢复
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
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('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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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': './',
|
'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'],
|
||||||
|
|||||||