Lock Android portrait via fullscreen then orientation.lock.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,17 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-27 — 安卓竖屏:全屏后系统锁定
|
||||
|
||||
### 变更
|
||||
|
||||
1. 说明:安卓 **Chrome 普通标签页** 不允许网页直接锁方向,必须先全屏再 `orientation.lock`(你以前能锁的,多半是 App/WebView/已装桌面,或点过全屏)。
|
||||
2. 安卓首次点击 / 「点击锁定竖屏」:进入全屏并系统锁竖屏。
|
||||
3. 已「添加到主屏幕」的应用走 manifest `orientation: portrait`,一般无需全屏。
|
||||
4. 补充 X5/UC 竖屏 meta;锁失败时仍强制竖屏布局。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-27 — 手机端真·竖屏(禁横屏布局)
|
||||
|
||||
### 变更
|
||||
|
||||
+8
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#0b0e11" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
@@ -13,6 +13,13 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="比特骆驼" />
|
||||
<meta name="application-name" content="比特骆驼" />
|
||||
<!-- 安卓 WebView / X5 / UC:声明竖屏(Chrome 仍需 JS 全屏+lock 或安装 PWA) -->
|
||||
<meta name="screen-orientation" content="portrait" />
|
||||
<meta name="x5-orientation" content="portrait" />
|
||||
<meta name="x5-page-mode" content="app" />
|
||||
<meta name="browsermode" content="application" />
|
||||
<meta name="full-screen" content="yes" />
|
||||
<meta name="x5-fullscreen" content="true" />
|
||||
<meta
|
||||
name="description"
|
||||
content="比特骆驼自动化对冲系统:ETH 永续+期权对冲监控与策略控制台,支持电脑与平板安装为独立应用。"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"start_url": "/plan",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "fullscreen", "minimal-ui"],
|
||||
"orientation": "portrait-primary",
|
||||
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0b0e11",
|
||||
"theme_color": "#0b0e11",
|
||||
"lang": "zh-CN",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* 手机端竖屏锁定:
|
||||
* 1) 能锁系统方向时直接 lock(安卓 PWA / 部分全屏环境)
|
||||
* 2) 锁不住时,横屏仍强制按竖屏布局渲染(不进入横屏 UI)
|
||||
* 安卓竖屏锁定:
|
||||
* - 已安装 PWA / 部分 WebView:直接 orientation.lock
|
||||
* - Chrome 普通标签页:必须先全屏再 lock(系统策略)
|
||||
* - 仍失败:用 JS 把界面强制按竖屏坐标系渲染(程序不进横屏布局)
|
||||
*/
|
||||
import { isStandaloneDisplay } from "./install";
|
||||
|
||||
type OrientationLockType =
|
||||
| "any"
|
||||
@@ -14,68 +16,228 @@ type OrientationLockType =
|
||||
| "landscape-primary"
|
||||
| "landscape-secondary";
|
||||
|
||||
let osLocked = false;
|
||||
|
||||
function isAndroid(): boolean {
|
||||
return /Android/i.test(navigator.userAgent || "");
|
||||
}
|
||||
|
||||
function isPhoneLike(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const ua = navigator.userAgent || "";
|
||||
if (/Android.+Mobile|iPhone|iPod|Windows Phone/i.test(ua)) return true;
|
||||
if (/iPhone|iPod|Windows Phone/i.test(ua)) return true;
|
||||
if (/Android/i.test(ua)) {
|
||||
if (/Mobile/i.test(ua)) return true;
|
||||
return Math.min(window.screen.width, window.screen.height) <= 600;
|
||||
}
|
||||
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||
const shortSide = Math.min(window.screen.width, window.screen.height);
|
||||
return coarse && shortSide <= 520;
|
||||
}
|
||||
|
||||
function isLandscape(): boolean {
|
||||
return window.matchMedia("(orientation: landscape)").matches;
|
||||
return window.innerWidth > window.innerHeight;
|
||||
}
|
||||
|
||||
async function tryLockPortrait(): Promise<boolean> {
|
||||
if (!isPhoneLike()) return false;
|
||||
function tryLegacyLock(): boolean {
|
||||
const s = screen as Screen & {
|
||||
lockOrientation?: (o: string) => boolean;
|
||||
mozLockOrientation?: (o: string) => boolean;
|
||||
msLockOrientation?: (o: string) => boolean;
|
||||
};
|
||||
try {
|
||||
if (s.lockOrientation?.("portrait") || s.lockOrientation?.("portrait-primary")) {
|
||||
return true;
|
||||
}
|
||||
if (s.mozLockOrientation?.("portrait")) return true;
|
||||
if (s.msLockOrientation?.("portrait")) return true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function tryModernLock(): Promise<boolean> {
|
||||
const orient = screen.orientation as ScreenOrientation & {
|
||||
lock?: (orientation: OrientationLockType) => Promise<void>;
|
||||
};
|
||||
if (!orient?.lock) return false;
|
||||
try {
|
||||
await orient.lock("portrait");
|
||||
return true;
|
||||
} catch {
|
||||
for (const mode of ["portrait", "portrait-primary"] as OrientationLockType[]) {
|
||||
try {
|
||||
await orient.lock("portrait-primary");
|
||||
await orient.lock(mode);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function syncForcedPortrait(): void {
|
||||
async function ensureFullscreen(): Promise<boolean> {
|
||||
if (document.fullscreenElement) return true;
|
||||
const el = document.documentElement as HTMLElement & {
|
||||
webkitRequestFullscreen?: () => void;
|
||||
};
|
||||
try {
|
||||
if (typeof el.requestFullscreen === "function") {
|
||||
await el.requestFullscreen(
|
||||
{ navigationUI: "hide" } as FullscreenOptions,
|
||||
);
|
||||
return Boolean(document.fullscreenElement);
|
||||
}
|
||||
if (typeof el.webkitRequestFullscreen === "function") {
|
||||
el.webkitRequestFullscreen();
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return Boolean(document.fullscreenElement);
|
||||
}
|
||||
|
||||
/** 系统级竖屏锁。fromGesture=true 时安卓浏览器可先全屏再锁。 */
|
||||
export async function lockPortraitHard(fromGesture: boolean): Promise<boolean> {
|
||||
if (!isPhoneLike()) return false;
|
||||
if (tryLegacyLock()) {
|
||||
osLocked = true;
|
||||
return true;
|
||||
}
|
||||
if (await tryModernLock()) {
|
||||
osLocked = true;
|
||||
return true;
|
||||
}
|
||||
// Chrome 标签页:全屏是 lock 的前置条件
|
||||
if (fromGesture && isAndroid() && !isStandaloneDisplay()) {
|
||||
await ensureFullscreen();
|
||||
if (tryLegacyLock() || (await tryModernLock())) {
|
||||
osLocked = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function clearRootForce(root: HTMLElement): void {
|
||||
root.style.position = "";
|
||||
root.style.width = "";
|
||||
root.style.height = "";
|
||||
root.style.left = "";
|
||||
root.style.top = "";
|
||||
root.style.transform = "";
|
||||
root.style.transformOrigin = "";
|
||||
root.style.overflow = "";
|
||||
root.style.zIndex = "";
|
||||
}
|
||||
|
||||
/** 系统锁失败且当前横屏时,强制竖屏坐标系(程序不走横屏布局)。 */
|
||||
function syncForcedPortraitLayout(): void {
|
||||
const html = document.documentElement;
|
||||
const root = document.getElementById("root");
|
||||
if (!root) return;
|
||||
|
||||
if (!isPhoneLike()) {
|
||||
html.classList.remove("phone-portrait-lock", "phone-landscape");
|
||||
clearRootForce(root);
|
||||
return;
|
||||
}
|
||||
|
||||
html.classList.add("phone-portrait-lock");
|
||||
html.classList.toggle("phone-landscape", isLandscape());
|
||||
|
||||
// 系统已锁且当前是竖屏:清强制样式
|
||||
if (osLocked && !isLandscape()) {
|
||||
html.classList.remove("phone-landscape");
|
||||
clearRootForce(root);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLandscape()) {
|
||||
html.classList.remove("phone-landscape");
|
||||
clearRootForce(root);
|
||||
return;
|
||||
}
|
||||
|
||||
html.classList.add("phone-landscape");
|
||||
const w = window.innerHeight;
|
||||
const h = window.innerWidth;
|
||||
root.style.position = "fixed";
|
||||
root.style.width = `${w}px`;
|
||||
root.style.height = `${h}px`;
|
||||
root.style.left = "0px";
|
||||
root.style.top = `${w}px`;
|
||||
root.style.transformOrigin = "left top";
|
||||
root.style.transform = "rotate(-90deg)";
|
||||
root.style.overflow = "auto";
|
||||
root.style.zIndex = "1";
|
||||
root.style.setProperty("-webkit-overflow-scrolling", "touch");
|
||||
}
|
||||
|
||||
/** 手机端:系统竖屏锁 + 横屏时强制竖屏排版。 */
|
||||
function syncHint(): void {
|
||||
let hint = document.getElementById("portrait-lock-hint");
|
||||
const need =
|
||||
isPhoneLike() &&
|
||||
isAndroid() &&
|
||||
!isStandaloneDisplay() &&
|
||||
!osLocked &&
|
||||
!document.fullscreenElement;
|
||||
|
||||
if (!need) {
|
||||
hint?.remove();
|
||||
return;
|
||||
}
|
||||
if (!hint) {
|
||||
hint = document.createElement("button");
|
||||
hint.id = "portrait-lock-hint";
|
||||
hint.type = "button";
|
||||
hint.className = "portrait-lock-hint";
|
||||
hint.textContent = "点击锁定竖屏";
|
||||
hint.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
void (async () => {
|
||||
await lockPortraitHard(true);
|
||||
syncForcedPortraitLayout();
|
||||
syncHint();
|
||||
})();
|
||||
});
|
||||
document.body.appendChild(hint);
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动竖屏锁定。安卓浏览器需点一下(全屏+锁);已装主屏幕应用通常自动锁。 */
|
||||
export function initPortraitLock(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const sync = () => {
|
||||
syncForcedPortrait();
|
||||
void tryLockPortrait();
|
||||
syncForcedPortraitLayout();
|
||||
syncHint();
|
||||
};
|
||||
|
||||
void lockPortraitHard(false).then(sync);
|
||||
sync();
|
||||
window.addEventListener("orientationchange", sync);
|
||||
|
||||
window.addEventListener("orientationchange", () => {
|
||||
window.setTimeout(sync, 50);
|
||||
void lockPortraitHard(false).then(sync);
|
||||
});
|
||||
window.addEventListener("resize", sync);
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
if (!document.fullscreenElement) osLocked = false;
|
||||
sync();
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") sync();
|
||||
if (document.visibilityState === "visible") {
|
||||
void lockPortraitHard(false).then(sync);
|
||||
}
|
||||
});
|
||||
|
||||
// 部分浏览器要求用户手势后才能 orientation.lock
|
||||
const onGesture = () => {
|
||||
void tryLockPortrait().then(() => syncForcedPortrait());
|
||||
void lockPortraitHard(true).then(sync);
|
||||
};
|
||||
window.addEventListener("pointerdown", onGesture, { passive: true });
|
||||
window.addEventListener("touchstart", onGesture, { passive: true });
|
||||
// 捕获阶段,保证第一次触摸就能触发全屏+锁
|
||||
window.addEventListener("pointerdown", onGesture, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener("touchstart", onGesture, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
+18
-16
@@ -34,8 +34,8 @@ body {
|
||||
}
|
||||
|
||||
/*
|
||||
* 手机横屏时不进入横屏布局:把 #root 旋成竖屏坐标系。
|
||||
* 能调用 screen.orientation.lock 的环境(如安卓已安装 PWA)则系统本身不转,此类样式不生效。
|
||||
* 手机横屏兜底:JS 会写 #root 行内样式;此处仅约束 html/body。
|
||||
* 安卓 Chrome 普通页需「全屏 + orientation.lock」才能系统级锁竖屏。
|
||||
*/
|
||||
html.phone-portrait-lock.phone-landscape,
|
||||
html.phone-portrait-lock.phone-landscape body {
|
||||
@@ -50,20 +50,22 @@ html.phone-portrait-lock.phone-landscape body {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
html.phone-portrait-lock.phone-landscape #root {
|
||||
position: absolute;
|
||||
width: 100vh;
|
||||
width: 100dvh;
|
||||
height: 100vw;
|
||||
height: 100dvw;
|
||||
top: 100%;
|
||||
top: 100dvh;
|
||||
left: 0;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: left top;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: none;
|
||||
.portrait-lock-hint {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
margin: 0;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid rgba(240, 185, 11, 0.45);
|
||||
border-radius: 999px;
|
||||
background: rgba(11, 14, 17, 0.92);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
button,
|
||||
|
||||
Reference in New Issue
Block a user