feat(me): 添加无摄像头权限时的直接进入功能

- 新增直接进入按钮和倒计时提示界面
- 实现5秒后自动进入档案馆逻辑
- 添加摄像头权限状态管理和重试机制
- 支持点击按钮立即跳过摄像头授权
- 更新多语言字典中的相关提示文本
- 调整粒子爆炸效果参数提升视觉体验
- 优化手势识别逻辑仅在摄像头启用时运行
- 添加重新申请摄像头权限的交互入口
This commit is contained in:
hehh
2025-12-04 18:52:09 +08:00
parent 0f6504a46b
commit a2b627b4ed

256
me.html
View File

@@ -104,6 +104,15 @@
transform: translateY(0); transform: translateY(0);
} }
.direct-enter-container {
text-align: center;
}
.direct-enter-btn:hover {
background: var(--accent-color);
color: var(--loader-bg);
}
/* === 2. 叙事文本层 (DOM覆盖) === */ /* === 2. 叙事文本层 (DOM覆盖) === */
#narrative-layer { #narrative-layer {
position: absolute; position: absolute;
@@ -358,6 +367,10 @@
<div class="loader-ring"></div> <div class="loader-ring"></div>
<div class="status-text" id="loader-msg">正在唤醒灵感...</div> <div class="status-text" id="loader-msg">正在唤醒灵感...</div>
<div class="perm-hint" id="perm-guide">⚠ 请允许摄像头权限以开启手势交互</div> <div class="perm-hint" id="perm-guide">⚠ 请允许摄像头权限以开启手势交互</div>
<div class="direct-enter-container" id="direct-enter-container" style="margin-top: 20px; opacity: 0; transition: opacity 1s;">
<div class="direct-enter-hint" id="direct-enter-hint" style="font-size: 14px; margin-bottom: 15px;">3秒后自动进入</div>
<button class="direct-enter-btn" id="direct-enter-btn" style="padding: 10px 20px; background: transparent; border: 1px solid var(--accent-color); color: var(--loader-text); border-radius: 20px; cursor: pointer; font-size: 12px;">立即进入</button>
</div>
</div> </div>
<!-- 2. 叙事文本层 (DOM) --> <!-- 2. 叙事文本层 (DOM) -->
@@ -412,12 +425,15 @@
startScreen: document.getElementById('start-screen'), startScreen: document.getElementById('start-screen'),
loaderMsg: document.getElementById('loader-msg'), loaderMsg: document.getElementById('loader-msg'),
permHint: document.getElementById('perm-guide'), permHint: document.getElementById('perm-guide'),
directEnterContainer: document.getElementById('direct-enter-container'),
directEnterHint: document.getElementById('direct-enter-hint'),
directEnterBtn: document.getElementById('direct-enter-btn'),
// 叙事层相关元素 // 叙事层相关元素
narrativeLayer: document.getElementById('narrative-layer'), narrativeLayer: document.getElementById('narrative-layer'),
nTitle: document.getElementById('n-title'), nTitle: document.getElementById('n-title'),
nSub: document.getElementById('n-sub'), nSub: document.getElementById('n-sub'),
// UI层相关元素 // UI层相关元素
uiLayer: document.getElementById('ui-layer'), uiLayer: document.getElementById('ui-layer'),
sysStatus: document.getElementById('sys-status'), sysStatus: document.getElementById('sys-status'),
@@ -426,7 +442,7 @@
mainHint: document.getElementById('main-hint'), mainHint: document.getElementById('main-hint'),
subHint: document.getElementById('sub-hint'), subHint: document.getElementById('sub-hint'),
exitBtn: document.getElementById('exit-btn'), exitBtn: document.getElementById('exit-btn'),
// 其他元素 // 其他元素
canvasContainer: document.getElementById('canvas-container'), canvasContainer: document.getElementById('canvas-container'),
inputVideo: document.getElementById('input-video') inputVideo: document.getElementById('input-video')
@@ -473,6 +489,14 @@
lang: getStoredLanguage() lang: getStoredLanguage()
}; };
// 摄像头权限状态管理
const CAMERA_STATE = {
enabled: false, // 摄像头是否已启用
permissionDenied: false, // 用户是否拒绝了权限
autoEnterTimeout: null, // 自动进入计时器
retryTimeout: null // 重试计时器
};
// 应用主题 // 应用主题
document.documentElement.setAttribute('data-theme', ENV.theme); document.documentElement.setAttribute('data-theme', ENV.theme);
safeUpdateText(DOM_CACHE.themeDisplay, `THEME: ${ENV.lang.toUpperCase() === 'en' ? ENV.theme.toUpperCase() : ENV.theme === 'day' ? '白天' : '黑夜'}`); safeUpdateText(DOM_CACHE.themeDisplay, `THEME: ${ENV.lang.toUpperCase() === 'en' ? ENV.theme.toUpperCase() : ENV.theme === 'day' ? '白天' : '黑夜'}`);
@@ -491,7 +515,12 @@
hints: { hints: {
main: "双手合十 · 解锁档案", main: "双手合十 · 解锁档案",
sub: "单手·引力牵引|双手·力场排斥", sub: "单手·引力牵引|双手·力场排斥",
unlocking: "正在识别..." unlocking: "正在识别...",
directEnterHint: "5秒后自动进入",
directEnterBtn: "立即进入",
cameraDisabledMainHint: "点击进入档案馆",
cameraDisabledSubHint: "[ 再次申请摄像头权限 ]",
cameraRetryTimeout: "摄像头授权超时5秒后取消"
}, },
slides: [ slides: [
{t: "初心", s: "在这喧嚣世界中,依然相信纯粹的力量"}, {t: "初心", s: "在这喧嚣世界中,依然相信纯粹的力量"},
@@ -509,7 +538,12 @@
hints: { hints: {
main: "Click or Namaste", main: "Click or Namaste",
sub: "One Hand Drag · Two Hands Repel", sub: "One Hand Drag · Two Hands Repel",
unlocking: "Identifying..." unlocking: "Identifying...",
directEnterHint: "Auto enter in 5 seconds",
directEnterBtn: "Enter Now",
cameraDisabledMainHint: "Click to Enter Archive",
cameraDisabledSubHint: "[ Request Camera Access Again ]",
cameraRetryTimeout: "Camera authorization timeout, canceling in 5 seconds"
}, },
slides: [ slides: [
{t: "Honesty", s: "In a noisy world, still believe in the power of simplicity"}, {t: "Honesty", s: "In a noisy world, still believe in the power of simplicity"},
@@ -524,6 +558,16 @@
const CONTENT = DICTIONARY[ENV.lang]; const CONTENT = DICTIONARY[ENV.lang];
// 设置直接进入按钮的文本
safeUpdateText(DOM_CACHE.directEnterBtn, CONTENT.hints.directEnterBtn);
safeUpdateText(DOM_CACHE.directEnterHint, CONTENT.hints.directEnterHint);
// 直接进入按钮事件处理
DOM_CACHE.directEnterBtn.addEventListener('click', () => {
clearTimeout(CAMERA_STATE.autoEnterTimeout);
enterWithoutCamera();
});
// 循环播放加载文案 // 循环播放加载文案
let loadIdx = 0; let loadIdx = 0;
const maxIdx = CONTENT.load.length - 1; const maxIdx = CONTENT.load.length - 1;
@@ -536,9 +580,19 @@
loadIdx++; loadIdx++;
}, 600); }, 600);
// 权限超时提示 // 权限超时提示和自动进入功能
setTimeout(() => { setTimeout(() => {
if (!APP_STATE.isLoaded) safeClass(DOM_CACHE.permHint, 'add', 'show'); if (!APP_STATE.isLoaded) {
safeClass(DOM_CACHE.permHint, 'add', 'show');
// 显示直接进入选项
DOM_CACHE.directEnterContainer.style.opacity = '1';
// 3秒后自动进入
CAMERA_STATE.autoEnterTimeout = setTimeout(() => {
enterWithoutCamera();
}, 5000);
}
}, 5000); }, 5000);
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.main); safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.main);
@@ -808,9 +862,41 @@
*/ */
let narrativeTimer = null; let narrativeTimer = null;
// 无摄像头进入档案馆
function enterWithoutCamera() {
if (APP_STATE.mode === 'UNLOCKED') return;
APP_STATE.mode = 'UNLOCKED';
CAMERA_STATE.enabled = false;
// 隐藏加载屏幕
const loader = DOM_CACHE.startScreen;
loader.style.opacity = 0;
DOM_CACHE.uiLayer.style.opacity = 1;
setTimeout(() => loader.style.display = 'none', 1000);
// 更新UI提示
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.cameraDisabledMainHint);
safeUpdateText(DOM_CACHE.subHint, CONTENT.hints.cameraDisabledSubHint);
// 显示退出按钮
safeClass(DOM_CACHE.exitBtn, 'add', 'visible');
// 粒子爆炸效果
explode(300);
startNarrative();
}
window.enterArchive = function () { window.enterArchive = function () {
if (APP_STATE.mode === 'UNLOCKED') return; if (APP_STATE.mode === 'UNLOCKED') return;
APP_STATE.mode = 'UNLOCKED'; APP_STATE.mode = 'UNLOCKED';
CAMERA_STATE.enabled = true;
// 隐藏加载屏幕
const loader = DOM_CACHE.startScreen;
loader.style.opacity = 0;
DOM_CACHE.uiLayer.style.opacity = 1;
setTimeout(() => loader.style.display = 'none', 1000);
// UI // UI
safeClass(DOM_CACHE.mainHint, 'add', 'hidden'); // 隐藏主提示 (CSS需支持或直接display) safeClass(DOM_CACHE.mainHint, 'add', 'hidden'); // 隐藏主提示 (CSS需支持或直接display)
@@ -819,7 +905,7 @@
safeClass(DOM_CACHE.exitBtn, 'add', 'visible'); safeClass(DOM_CACHE.exitBtn, 'add', 'visible');
// 粒子爆炸效果 // 粒子爆炸效果
explode(100); explode(300);
startNarrative(); startNarrative();
} }
@@ -828,13 +914,23 @@
APP_STATE.mode = 'LOCKED'; APP_STATE.mode = 'LOCKED';
APP_STATE.exitCooldownUntil = Date.now() + 2000; APP_STATE.exitCooldownUntil = Date.now() + 2000;
DOM_CACHE.mainHint.style.display = 'block'; // 根据摄像头状态更新UI
DOM_CACHE.subHint.style.display = 'block'; if (CAMERA_STATE.enabled) {
DOM_CACHE.mainHint.style.display = 'block';
DOM_CACHE.subHint.style.display = 'block';
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.main);
safeUpdateText(DOM_CACHE.subHint, CONTENT.hints.sub);
} else {
DOM_CACHE.mainHint.style.display = 'block';
DOM_CACHE.subHint.style.display = 'block';
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.cameraDisabledMainHint);
safeUpdateText(DOM_CACHE.subHint, CONTENT.hints.cameraDisabledSubHint);
}
safeClass(DOM_CACHE.exitBtn, 'remove', 'visible'); safeClass(DOM_CACHE.exitBtn, 'remove', 'visible');
safeClass(DOM_CACHE.narrativeLayer, 'remove', 'show-text'); safeClass(DOM_CACHE.narrativeLayer, 'remove', 'show-text');
APP_STATE.unlockProgress = 0; APP_STATE.unlockProgress = 0;
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.main);
clearTimeout(narrativeTimer); clearTimeout(narrativeTimer);
explode(50); explode(50);
} }
@@ -897,68 +993,98 @@
// Loader logic // Loader logic
if (!APP_STATE.isLoaded) { if (!APP_STATE.isLoaded) {
APP_STATE.isLoaded = true; APP_STATE.isLoaded = true;
const loader = DOM_CACHE.startScreen; CAMERA_STATE.enabled = true;
loader.style.opacity = 0;
DOM_CACHE.uiLayer.style.opacity = 1; // 清除自动进入定时器
setTimeout(() => loader.style.display = 'none', 1000); clearTimeout(CAMERA_STATE.autoEnterTimeout);
} }
const landmarks = results.multiHandLandmarks; // 只有在摄像头启用时才处理手势
const sysStatus = DOM_CACHE.sysStatus; if (CAMERA_STATE.enabled) {
const landmarks = results.multiHandLandmarks;
const sysStatus = DOM_CACHE.sysStatus;
if (landmarks && landmarks.length > 0) { if (landmarks && landmarks.length > 0) {
APP_STATE.handCount = landmarks.length; APP_STATE.handCount = landmarks.length;
safeUpdateText(sysStatus, `LINKED (${APP_STATE.handCount})`); safeUpdateText(sysStatus, `LINKED (${APP_STATE.handCount})`);
if (sysStatus) sysStatus.style.color = ENV.theme === 'day' ? '#0044cc' : '#00ff00'; if (sysStatus) sysStatus.style.color = ENV.theme === 'day' ? '#0044cc' : '#00ff00';
// 坐标处理 (镜像) // 坐标处理 (镜像)
const process = (lm) => ({ const process = (lm) => ({
x: ((1.0 - lm.x) * 2 - 1) * 800, x: ((1.0 - lm.x) * 2 - 1) * 800,
y: -(lm.y * 2 - 1 - 0.2) * 600 y: -(lm.y * 2 - 1 - 0.2) * 600
}); });
const p1 = process(landmarks[0][9]); const p1 = process(landmarks[0][9]);
APP_STATE.handL.x += (p1.x - APP_STATE.handL.x) * 0.25; APP_STATE.handL.x += (p1.x - APP_STATE.handL.x) * 0.25;
APP_STATE.handL.y += (p1.y - APP_STATE.handL.y) * 0.25; APP_STATE.handL.y += (p1.y - APP_STATE.handL.y) * 0.25;
if (landmarks.length > 1) { if (landmarks.length > 1) {
const p2 = process(landmarks[1][9]); const p2 = process(landmarks[1][9]);
APP_STATE.handR.x += (p2.x - APP_STATE.handR.x) * 0.25; APP_STATE.handR.x += (p2.x - APP_STATE.handR.x) * 0.25;
APP_STATE.handR.y += (p2.y - APP_STATE.handR.y) * 0.25; APP_STATE.handR.y += (p2.y - APP_STATE.handR.y) * 0.25;
}
// 合十检测
if (landmarks.length === 2 && APP_STATE.mode === 'LOCKED' && Date.now() > APP_STATE.exitCooldownUntil) {
const w1 = landmarks[0][0], w2 = landmarks[1][0];
const i1 = landmarks[0][8], i2 = landmarks[1][8];
const m1 = landmarks[0][12], m2 = landmarks[1][12];
const dW = Math.hypot(w1.x - w2.x, w1.y - w2.y);
const dI = Math.hypot(i1.x - i2.x, i1.y - i2.y);
const dM = Math.hypot(m1.x - m2.x, m1.y - m2.y);
const okW = dW < 0.28;
const okI = dI < 0.20;
const okM = dM < 0.20;
const ok = ((okW ? 1 : 0) + (okI ? 1 : 0) + (okM ? 1 : 0)) >= 2;
if (ok) {
APP_STATE.namasteStableFrames++;
} else {
APP_STATE.namasteStableFrames = Math.max(0, APP_STATE.namasteStableFrames - 1);
} }
const targetFrames = 20;
const pct = Math.min(100, Math.round(APP_STATE.namasteStableFrames / targetFrames * 100)); // 合十检测
if (pct > 0) safeUpdateText(DOM_CACHE.mainHint, `${CONTENT.hints.unlocking} ${pct}%`); if (landmarks.length === 2 && APP_STATE.mode === 'LOCKED' && Date.now() > APP_STATE.exitCooldownUntil) {
if (APP_STATE.namasteStableFrames >= targetFrames) enterArchive(); const w1 = landmarks[0][0], w2 = landmarks[1][0];
if (!ok && APP_STATE.namasteStableFrames === 0) safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.main); const i1 = landmarks[0][8], i2 = landmarks[1][8];
const m1 = landmarks[0][12], m2 = landmarks[1][12];
const dW = Math.hypot(w1.x - w2.x, w1.y - w2.y);
const dI = Math.hypot(i1.x - i2.x, i1.y - i2.y);
const dM = Math.hypot(m1.x - m2.x, m1.y - m2.y);
const okW = dW < 0.28;
const okI = dI < 0.20;
const okM = dM < 0.20;
const ok = ((okW ? 1 : 0) + (okI ? 1 : 0) + (okM ? 1 : 0)) >= 2;
if (ok) {
APP_STATE.namasteStableFrames++;
} else {
APP_STATE.namasteStableFrames = Math.max(0, APP_STATE.namasteStableFrames - 1);
}
const targetFrames = 20;
const pct = Math.min(100, Math.round(APP_STATE.namasteStableFrames / targetFrames * 100));
if (pct > 0) safeUpdateText(DOM_CACHE.mainHint, `${CONTENT.hints.unlocking} ${pct}%`);
if (APP_STATE.namasteStableFrames >= targetFrames) enterArchive();
if (!ok && APP_STATE.namasteStableFrames === 0) safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.main);
}
} else {
APP_STATE.handCount = 0;
safeUpdateText(sysStatus, 'SEARCHING...');
if (sysStatus) sysStatus.style.color = 'inherit';
APP_STATE.unlockProgress = 0;
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.main);
} }
} else {
APP_STATE.handCount = 0;
safeUpdateText(sysStatus, 'SEARCHING...');
if (sysStatus) sysStatus.style.color = 'inherit';
APP_STATE.unlockProgress = 0;
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.main);
} }
}); });
DOM_CACHE.subHint.addEventListener('click', () => {
// 只有在无摄像头模式下才允许重新申请权限
if (!CAMERA_STATE.enabled && APP_STATE.mode === 'UNLOCKED') {
requestCameraAccess();
}
});
// 请求摄像头权限
function requestCameraAccess() {
// 更新提示文本
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.cameraRetryTimeout);
// 尝试启动摄像头
cameraUtils.start().then(() => {
// 成功启动,隐藏提示
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.main);
}).catch(err => {
console.log("Camera access error:", err);
});
// 5秒后取消
CAMERA_STATE.retryTimeout = setTimeout(() => {
cameraUtils.stop();
safeUpdateText(DOM_CACHE.mainHint, CONTENT.hints.cameraDisabledMainHint);
}, 5000);
}
const videoElement = DOM_CACHE.inputVideo; const videoElement = DOM_CACHE.inputVideo;
const cameraUtils = new Camera(videoElement, { const cameraUtils = new Camera(videoElement, {
onFrame: async () => { onFrame: async () => {
@@ -966,7 +1092,11 @@
}, },
width: 640, height: 480 width: 640, height: 480
}); });
cameraUtils.start();
// 初始启动摄像头
cameraUtils.start().catch(err => {
console.log("Initial camera access denied:", err);
});
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight; camera.aspect = window.innerWidth / window.innerHeight;