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 — 手机端真·竖屏(禁横屏布局)
|
## 2026-07-27 — 手机端真·竖屏(禁横屏布局)
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
+8
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
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="theme-color" content="#0b0e11" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<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-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="比特骆驼" />
|
<meta name="apple-mobile-web-app-title" content="比特骆驼" />
|
||||||
<meta name="application-name" 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
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="比特骆驼自动化对冲系统:ETH 永续+期权对冲监控与策略控制台,支持电脑与平板安装为独立应用。"
|
content="比特骆驼自动化对冲系统:ETH 永续+期权对冲监控与策略控制台,支持电脑与平板安装为独立应用。"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"start_url": "/plan",
|
"start_url": "/plan",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"display_override": ["standalone", "fullscreen", "minimal-ui"],
|
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait",
|
||||||
"background_color": "#0b0e11",
|
"background_color": "#0b0e11",
|
||||||
"theme_color": "#0b0e11",
|
"theme_color": "#0b0e11",
|
||||||
"lang": "zh-CN",
|
"lang": "zh-CN",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 手机端竖屏锁定:
|
* 安卓竖屏锁定:
|
||||||
* 1) 能锁系统方向时直接 lock(安卓 PWA / 部分全屏环境)
|
* - 已安装 PWA / 部分 WebView:直接 orientation.lock
|
||||||
* 2) 锁不住时,横屏仍强制按竖屏布局渲染(不进入横屏 UI)
|
* - Chrome 普通标签页:必须先全屏再 lock(系统策略)
|
||||||
|
* - 仍失败:用 JS 把界面强制按竖屏坐标系渲染(程序不进横屏布局)
|
||||||
*/
|
*/
|
||||||
|
import { isStandaloneDisplay } from "./install";
|
||||||
|
|
||||||
type OrientationLockType =
|
type OrientationLockType =
|
||||||
| "any"
|
| "any"
|
||||||
@@ -14,68 +16,228 @@ type OrientationLockType =
|
|||||||
| "landscape-primary"
|
| "landscape-primary"
|
||||||
| "landscape-secondary";
|
| "landscape-secondary";
|
||||||
|
|
||||||
|
let osLocked = false;
|
||||||
|
|
||||||
|
function isAndroid(): boolean {
|
||||||
|
return /Android/i.test(navigator.userAgent || "");
|
||||||
|
}
|
||||||
|
|
||||||
function isPhoneLike(): boolean {
|
function isPhoneLike(): boolean {
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
const ua = navigator.userAgent || "";
|
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 coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||||
const shortSide = Math.min(window.screen.width, window.screen.height);
|
const shortSide = Math.min(window.screen.width, window.screen.height);
|
||||||
return coarse && shortSide <= 520;
|
return coarse && shortSide <= 520;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLandscape(): boolean {
|
function isLandscape(): boolean {
|
||||||
return window.matchMedia("(orientation: landscape)").matches;
|
return window.innerWidth > window.innerHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryLockPortrait(): Promise<boolean> {
|
function tryLegacyLock(): boolean {
|
||||||
if (!isPhoneLike()) return false;
|
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 & {
|
const orient = screen.orientation as ScreenOrientation & {
|
||||||
lock?: (orientation: OrientationLockType) => Promise<void>;
|
lock?: (orientation: OrientationLockType) => Promise<void>;
|
||||||
};
|
};
|
||||||
if (!orient?.lock) return false;
|
if (!orient?.lock) return false;
|
||||||
|
for (const mode of ["portrait", "portrait-primary"] as OrientationLockType[]) {
|
||||||
try {
|
try {
|
||||||
await orient.lock("portrait");
|
await orient.lock(mode);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFullscreen(): Promise<boolean> {
|
||||||
|
if (document.fullscreenElement) return true;
|
||||||
|
const el = document.documentElement as HTMLElement & {
|
||||||
|
webkitRequestFullscreen?: () => void;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await orient.lock("portrait-primary");
|
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;
|
return true;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
return Boolean(document.fullscreenElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncForcedPortrait(): void {
|
/** 系统级竖屏锁。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 html = document.documentElement;
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
if (!isPhoneLike()) {
|
if (!isPhoneLike()) {
|
||||||
html.classList.remove("phone-portrait-lock", "phone-landscape");
|
html.classList.remove("phone-portrait-lock", "phone-landscape");
|
||||||
|
clearRootForce(root);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.classList.add("phone-portrait-lock");
|
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 {
|
export function initPortraitLock(): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
syncForcedPortrait();
|
syncForcedPortraitLayout();
|
||||||
void tryLockPortrait();
|
syncHint();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
void lockPortraitHard(false).then(sync);
|
||||||
sync();
|
sync();
|
||||||
window.addEventListener("orientationchange", sync);
|
|
||||||
|
window.addEventListener("orientationchange", () => {
|
||||||
|
window.setTimeout(sync, 50);
|
||||||
|
void lockPortraitHard(false).then(sync);
|
||||||
|
});
|
||||||
window.addEventListener("resize", sync);
|
window.addEventListener("resize", sync);
|
||||||
|
document.addEventListener("fullscreenchange", () => {
|
||||||
|
if (!document.fullscreenElement) osLocked = false;
|
||||||
|
sync();
|
||||||
|
});
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (document.visibilityState === "visible") sync();
|
if (document.visibilityState === "visible") {
|
||||||
|
void lockPortraitHard(false).then(sync);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 部分浏览器要求用户手势后才能 orientation.lock
|
|
||||||
const onGesture = () => {
|
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 旋成竖屏坐标系。
|
* 手机横屏兜底:JS 会写 #root 行内样式;此处仅约束 html/body。
|
||||||
* 能调用 screen.orientation.lock 的环境(如安卓已安装 PWA)则系统本身不转,此类样式不生效。
|
* 安卓 Chrome 普通页需「全屏 + orientation.lock」才能系统级锁竖屏。
|
||||||
*/
|
*/
|
||||||
html.phone-portrait-lock.phone-landscape,
|
html.phone-portrait-lock.phone-landscape,
|
||||||
html.phone-portrait-lock.phone-landscape body {
|
html.phone-portrait-lock.phone-landscape body {
|
||||||
@@ -50,20 +50,22 @@ html.phone-portrait-lock.phone-landscape body {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.phone-portrait-lock.phone-landscape #root {
|
.portrait-lock-hint {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
width: 100vh;
|
left: 50%;
|
||||||
width: 100dvh;
|
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||||
height: 100vw;
|
transform: translateX(-50%);
|
||||||
height: 100dvw;
|
z-index: 10000;
|
||||||
top: 100%;
|
margin: 0;
|
||||||
top: 100dvh;
|
padding: 10px 16px;
|
||||||
left: 0;
|
border: 1px solid rgba(240, 185, 11, 0.45);
|
||||||
transform: rotate(-90deg);
|
border-radius: 999px;
|
||||||
transform-origin: left top;
|
background: rgba(11, 14, 17, 0.92);
|
||||||
overflow: auto;
|
color: var(--accent);
|
||||||
-webkit-overflow-scrolling: touch;
|
font-size: 13px;
|
||||||
overscroll-behavior: none;
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
|
|||||||
Reference in New Issue
Block a user