Skip to content

Commit d8adfb5

Browse files
committed
fix(pomodoro): Chronos 交互补齐与构建级类型修复
- 点击生物交互:tap(<500ms)/长按(800ms)手势区分,沉睡/呼吸点击开始、专注点击暂停/继续、休息点击提前结束、长按回到沉睡 - ChronosCanvas 打通 pointer-events(Canvas 独立 auto),透传 onLongPress - 阶段过渡平滑:颜色/自发光 useFrame lerp(k=2.5/s),消除专注/休息切换瞬跳 - 性能降级:接入 useEffectiveTier,low/medium 档跳帧更新粒子与顶点扰动 - 环境光自适应修复:video muted + playsInline 满足自动播放策略,等待 loadeddata 后采样,复用隐藏元素 - 修复构建级类型错误:DiveProfileCard import 路径(../../ → ../../../)、audioPrefsStore 缺 AudioDeviceType import、ImmersiveTimer 误传不存在 props - 验证:tsc -b 0 错误、lint 干净、vitest 1011/1011
1 parent b999e05 commit d8adfb5

7 files changed

Lines changed: 197 additions & 44 deletions

File tree

‎client/src/features/pomodoro/components/ImmersiveTimer.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,23 @@ export default function ImmersiveTimer({
116116
pause,
117117
resume,
118118
reset,
119+
skip,
119120
} = usePomodoroStore(useShallow(s => s));
120121

122+
// Chronos 点击交互(时间生物 = 核心交互点):
123+
// 专注 → 暂停/继续;休息 → 提前结束休息;长按 → 沉睡
124+
const handleChronosTap = () => {
125+
if (phase === 'work') {
126+
if (isRunning) pause();
127+
else resume();
128+
} else {
129+
skip();
130+
}
131+
};
132+
const handleChronosLongPress = () => {
133+
reset();
134+
};
135+
121136
// 背景音偏好:外部传入优先(向后兼容),缺省读全局音频偏好 store
122137
const storePrefs = useAudioPrefsStore();
123138
const effectiveWhiteNoiseEnabled = whiteNoiseEnabled ?? storePrefs.whiteNoiseEnabled;
@@ -241,6 +256,8 @@ export default function ImmersiveTimer({
241256
intensity={focusScore > 0 ? focusScore : 50}
242257
ambientLight={ambientLight}
243258
bloom={bloom}
259+
onTap={handleChronosTap}
260+
onLongPress={handleChronosLongPress}
244261
timeStr={timeStr}
245262
label={label}
246263
/>

‎client/src/features/pomodoro/components/chronos/ChronosCanvas.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ interface ChronosCanvasProps {
2727
/** 完成绽放触发(P1) */
2828
bloom?: boolean;
2929
onTap?: () => void;
30+
/** 长按生物回调(进入沉睡) */
31+
onLongPress?: () => void;
3032
/** 中央显示内容(时间字符串) */
3133
timeStr: string;
3234
/** 中央显示标签(如 专注中) */
@@ -75,6 +77,7 @@ export function ChronosCanvas({
7577
ambientLight,
7678
bloom,
7779
onTap,
80+
onLongPress,
7881
timeStr,
7982
label,
8083
}: ChronosCanvasProps) {
@@ -101,6 +104,7 @@ export function ChronosCanvas({
101104
dpr={[1, 1.5]}
102105
gl={{ antialias: true, alpha: true }}
103106
className={mode === 'full' ? '!absolute inset-0' : ''}
107+
style={{ pointerEvents: 'auto' }}
104108
>
105109
<ambientLight intensity={0.6} />
106110
<pointLight position={[3, 3, 4]} intensity={1.2} />
@@ -112,6 +116,7 @@ export function ChronosCanvas({
112116
ambientLight={ambientLight}
113117
bloom={bloom}
114118
onTap={onTap}
119+
onLongPress={onLongPress}
115120
/>
116121
</Canvas>
117122
{/* 中央数字(HTML 层,与 3D 叠加) */}

‎client/src/features/pomodoro/components/chronos/ChronosSphere.tsx‎

Lines changed: 112 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@
1313
import { useRef, useMemo, useEffect } from 'react';
1414
import { useFrame } from '@react-three/fiber';
1515
import * as THREE from 'three';
16+
import { useEffectiveTier } from '@/lib/performance/usePerformanceMode';
1617
import type { ChronosPhase, ChronosStyle } from './chronosStyles';
1718
import { CHRONOS_PHASES } from './chronosStyles';
1819

20+
/** 长按判定阈值(ms):超过则触发 onLongPress 而非 onTap */
21+
const LONG_PRESS_MS = 800;
22+
/** 点击判定阈值(ms):超过则不视为 tap */
23+
const TAP_MAX_MS = 500;
24+
1925
interface ChronosSphereProps {
2026
/** 生物阶段 */
2127
phase: ChronosPhase;
@@ -29,6 +35,8 @@ interface ChronosSphereProps {
2935
bloom?: boolean;
3036
/** 点击生物回调 */
3137
onTap?: () => void;
38+
/** 长按生物回调(进入沉睡) */
39+
onLongPress?: () => void;
3240
}
3341

3442
/** 简易伪随机噪声(确定性,避免重渲染抖动) */
@@ -38,13 +46,24 @@ function noise(x: number, y: number, z: number, t: number): number {
3846
);
3947
}
4048

41-
export function ChronosSphere({ phase, style, intensity = 50, ambientLight = 0.5, bloom = false, onTap }: ChronosSphereProps) {
49+
export function ChronosSphere({ phase, style, intensity = 50, ambientLight = 0.5, bloom = false, onTap, onLongPress }: ChronosSphereProps) {
4250
const meshRef = useRef<THREE.Mesh>(null);
4351
const pointsRef = useRef<THREE.Points>(null);
4452
const ringRef = useRef<THREE.Mesh>(null);
4553
const ringMatRef = useRef<THREE.MeshBasicMaterial>(null);
4654
const materialRef = useRef<THREE.MeshStandardMaterial>(null);
4755

56+
// 性能档位(P3-15 同源):low 隔 2 帧更新粒子/顶点
57+
const tier = useEffectiveTier();
58+
const frameSkip = tier === 'low' ? 3 : tier === 'medium' ? 2 : 1;
59+
const frameRef = useRef(0);
60+
61+
// 当前值(useFrame 中向 targets 收敛,实现平滑过渡)
62+
const current = useRef({
63+
color: new THREE.Color(CHRONOS_PHASES[phase].body),
64+
emissive: CHRONOS_PHASES[phase].emissiveIntensity,
65+
});
66+
4867
// 目标值(阶段变化时更新,useFrame 中 lerp)
4968
const targets = useRef({
5069
bodyColor: new THREE.Color(CHRONOS_PHASES[phase].body),
@@ -61,6 +80,39 @@ export function ChronosSphere({ phase, style, intensity = 50, ambientLight = 0.5
6180
targets.current.particleRadius = palette.particleRadius;
6281
targets.current.spin = palette.spinSpeed;
6382

83+
// ── 手势:tap(<500ms)与 long-press(800ms)区分 ──
84+
const pointerDownRef = useRef(0);
85+
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
86+
const longPressFiredRef = useRef(false);
87+
88+
const cancelLongPress = () => {
89+
if (longPressTimerRef.current) {
90+
clearTimeout(longPressTimerRef.current);
91+
longPressTimerRef.current = null;
92+
}
93+
};
94+
95+
const handlePointerDown = (e: { stopPropagation: () => void }) => {
96+
e.stopPropagation();
97+
longPressFiredRef.current = false;
98+
pointerDownRef.current = Date.now();
99+
cancelLongPress();
100+
if (onLongPress) {
101+
longPressTimerRef.current = setTimeout(() => {
102+
longPressFiredRef.current = true;
103+
onLongPress();
104+
}, LONG_PRESS_MS);
105+
}
106+
};
107+
108+
const handlePointerUp = (e: { stopPropagation: () => void }) => {
109+
e.stopPropagation();
110+
cancelLongPress();
111+
if (longPressFiredRef.current) return;
112+
const elapsed = Date.now() - pointerDownRef.current;
113+
if (elapsed < TAP_MAX_MS && onTap) onTap();
114+
};
115+
64116
// 有机球体:细分二十面体 + 原始顶点缓存
65117
const geometry = useMemo(() => {
66118
const geo = new THREE.IcosahedronGeometry(1, 3);
@@ -111,11 +163,23 @@ export function ChronosSphere({ phase, style, intensity = 50, ambientLight = 0.5
111163
prevBloom.current = bloom;
112164
}, [bloom]);
113165

166+
// 卸载时清理长按定时器
167+
useEffect(() => {
168+
return () => {
169+
if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current);
170+
};
171+
}, []);
172+
114173
// 每帧动画
115174
useFrame(({ clock }) => {
116175
const t = clock.elapsedTime;
117176
const safeDelta = Math.min(clock.getDelta(), 0.1);
118177

178+
// 低档跳帧:非更新帧直接返回(呼吸缩放仍每帧执行,视觉无感)
179+
frameRef.current += 1;
180+
const shouldUpdate = frameRef.current % frameSkip === 0;
181+
const step = frameSkip;
182+
119183
// 绽放进度推进(P1 完成动画)
120184
if (isBlooming.current) {
121185
bloomRef.current = Math.min(1, bloomRef.current + safeDelta * 1.2);
@@ -129,50 +193,61 @@ export function ChronosSphere({ phase, style, intensity = 50, ambientLight = 0.5
129193
// 守护灵联动:低分 → 节奏放缓(P2)
130194
const focusFactor = 0.7 + (intensity / 100) * 0.6;
131195

196+
// 颜色/发光平滑过渡:向目标 lerp(阶段切换不再瞬跳)
197+
const lerpK = Math.min(safeDelta * 2.5, 1);
198+
current.current.color.lerp(targets.current.bodyColor, lerpK);
199+
current.current.emissive += (targets.current.emissive - current.current.emissive) * lerpK;
200+
132201
// 环境光补偿:暗环境增强自发光,亮环境减弱(P2)
133202
if (materialRef.current) {
134203
const ambientBoost = 1 + (0.5 - ambientLight) * 1.2;
135-
materialRef.current.emissiveIntensity = targets.current.emissive * Math.max(0.4, ambientBoost);
204+
materialRef.current.emissiveIntensity = current.current.emissive * Math.max(0.4, ambientBoost);
205+
materialRef.current.color.copy(current.current.color);
136206
}
137207

138208
// 呼吸缩放
139209
if (meshRef.current) {
140210
const breath = 1 + Math.sin(t * 1.2 * focusFactor) * targets.current.breathe;
141211
meshRef.current.scale.setScalar(breath * bloomPulse);
142212
meshRef.current.rotation.y += safeDelta * targets.current.spin * focusFactor;
213+
}
143214

215+
// 跳帧更新:顶点扰动 + 粒子(low/medium 档降开销)
216+
if (shouldUpdate) {
144217
// 顶点扰动(细胞膜)
145-
const pos = geometry.attributes.position as THREE.BufferAttribute;
146-
const original = geometry.userData.original as Float32Array;
147-
const amp = style.noiseAmplitude * (0.6 + targets.current.breathe * 8);
148-
for (let i = 0; i < pos.count; i++) {
149-
const i3 = i * 3;
150-
const ox = original[i3];
151-
const oy = original[i3 + 1];
152-
const oz = original[i3 + 2];
153-
const n = noise(ox * 4, oy * 4, oz * 4, t * focusFactor);
154-
pos.setXYZ(i3 / 3, ox + n * amp, oy + n * amp, oz + n * amp);
218+
if (meshRef.current) {
219+
const pos = geometry.attributes.position as THREE.BufferAttribute;
220+
const original = geometry.userData.original as Float32Array;
221+
const amp = style.noiseAmplitude * (0.6 + targets.current.breathe * 8);
222+
for (let i = 0; i < pos.count; i++) {
223+
const i3 = i * 3;
224+
const ox = original[i3];
225+
const oy = original[i3 + 1];
226+
const oz = original[i3 + 2];
227+
const n = noise(ox * 4, oy * 4, oz * 4, t * focusFactor);
228+
pos.setXYZ(i3 / 3, ox + n * amp, oy + n * amp, oz + n * amp);
229+
}
230+
pos.needsUpdate = true;
155231
}
156-
pos.needsUpdate = true;
157-
}
158232

159-
// 粒子:向球体聚拢/散开 + 绽放爆发
160-
if (pointsRef.current) {
161-
const posAttr = particleGeo.attributes.position as THREE.BufferAttribute;
162-
const targetR = targets.current.particleRadius;
163-
for (let i = 0; i < posAttr.count; i++) {
164-
const x = posAttr.getX(i);
165-
const y = posAttr.getY(i);
166-
const z = posAttr.getZ(i);
167-
const len = Math.sqrt(x * x + y * y + z * z) || 1;
168-
const targetLen = targetR * (isBlooming.current ? 1 + bloomRef.current * 2 : 1);
169-
// 向目标半径缓动
170-
const newLen = len + (targetLen - len) * Math.min(safeDelta * 1.5, 1);
171-
const k = newLen / len;
172-
posAttr.setXYZ(i, x * k, y * k, z * k);
233+
// 粒子:向球体聚拢/散开 + 绽放爆发
234+
if (pointsRef.current) {
235+
const posAttr = particleGeo.attributes.position as THREE.BufferAttribute;
236+
const targetR = targets.current.particleRadius;
237+
for (let i = 0; i < posAttr.count; i++) {
238+
const x = posAttr.getX(i);
239+
const y = posAttr.getY(i);
240+
const z = posAttr.getZ(i);
241+
const len = Math.sqrt(x * x + y * y + z * z) || 1;
242+
const targetLen = targetR * (isBlooming.current ? 1 + bloomRef.current * 2 : 1);
243+
// 向目标半径缓动(step 补偿跳帧)
244+
const newLen = len + (targetLen - len) * Math.min(safeDelta * step * 1.5, 1);
245+
const k = newLen / len;
246+
posAttr.setXYZ(i, x * k, y * k, z * k);
247+
}
248+
posAttr.needsUpdate = true;
249+
pointsRef.current.rotation.y += safeDelta * 0.15;
173250
}
174-
posAttr.needsUpdate = true;
175-
pointsRef.current.rotation.y += safeDelta * 0.15;
176251
}
177252

178253
// 呼吸波纹(扩散环)
@@ -185,14 +260,18 @@ export function ChronosSphere({ phase, style, intensity = 50, ambientLight = 0.5
185260
});
186261

187262
return (
188-
<group onClick={(e) => { e.stopPropagation(); onTap?.(); }}>
263+
<group
264+
onPointerDown={handlePointerDown}
265+
onPointerUp={handlePointerUp}
266+
onPointerLeave={cancelLongPress}
267+
>
189268
{/* 有机球体主体 */}
190269
<mesh ref={meshRef} geometry={geometry} castShadow>
191270
<meshStandardMaterial
192271
ref={materialRef}
193-
color={CHRONOS_PHASES[phase].body}
272+
color={current.current.color}
194273
emissive={style.emissiveColor}
195-
emissiveIntensity={targets.current.emissive}
274+
emissiveIntensity={current.current.emissive}
196275
roughness={0.35}
197276
metalness={0.15}
198277
flatShading

‎client/src/features/pomodoro/components/settings/dive/DiveProfileCard.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @ai-context: 深潜设置页改造——数据驱动卡片。
88
*/
99
import { Waves } from 'lucide-react';
10-
import type { DiveProfileStats } from '../../hooks/useDiveProfile';
10+
import type { DiveProfileStats } from '../../../hooks/useDiveProfile';
1111

1212
export function DiveProfileCard({ stats }: { stats: DiveProfileStats }) {
1313
return (

‎client/src/features/pomodoro/hooks/useAmbientLight.ts‎

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,29 @@
77
* 隐私优先:默认关闭(enabled=false 不申请摄像头权限);仅在设置页
88
* 显式开启后采样。采样频率 2s 一次,失败自动停用。
99
*
10+
* 实现要点:复用单个隐藏 video 元素(muted 满足自动播放策略),
11+
* 等待 loadeddata 后再采样,避免空白帧与 play() 被拒绝。
12+
*
1013
* @ai-context: Chronos P2 环境自适应 hook,getUserMedia 亮度采样。
1114
*/
1215
import { useState, useEffect, useRef, useCallback } from 'react';
1316

1417
const SAMPLE_INTERVAL_MS = 2000;
15-
const VIDEO_SIZE = 32; // 极低分辨率采样帧(1×1 像素足够测亮度)
18+
const VIDEO_SIZE = 32; // 极低分辨率采样帧
1619

1720
export function useAmbientLight(enabled: boolean): number {
1821
const [brightness, setBrightness] = useState(0.5); // 中性默认
1922
const streamRef = useRef<MediaStream | null>(null);
23+
const videoRef = useRef<HTMLVideoElement | null>(null);
24+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
2025

2126
const sample = useCallback(async () => {
2227
try {
23-
const video = document.createElement('video');
24-
video.srcObject = streamRef.current;
25-
await video.play();
26-
const canvas = document.createElement('canvas');
27-
canvas.width = VIDEO_SIZE;
28-
canvas.height = VIDEO_SIZE;
29-
const ctx = canvas.getContext('2d');
30-
if (!ctx) return;
28+
const video = videoRef.current;
29+
if (!video || video.readyState < 2) return; // 未就绪(无 loadeddata)跳过
30+
const canvas = canvasRef.current;
31+
const ctx = canvas?.getContext('2d');
32+
if (!canvas || !ctx) return;
3133
ctx.drawImage(video, 0, 0, VIDEO_SIZE, VIDEO_SIZE);
3234
const { data } = ctx.getImageData(0, 0, VIDEO_SIZE, VIDEO_SIZE);
3335
let sum = 0;
@@ -47,6 +49,7 @@ export function useAmbientLight(enabled: boolean): number {
4749
// 关闭:停止流并复位
4850
streamRef.current?.getTracks().forEach((t) => t.stop());
4951
streamRef.current = null;
52+
videoRef.current = null;
5053
setBrightness(0.5);
5154
return;
5255
}
@@ -63,6 +66,30 @@ export function useAmbientLight(enabled: boolean): number {
6366
return;
6467
}
6568
streamRef.current = stream;
69+
// 复用单个隐藏 video(muted 满足自动播放策略)+ 隐藏 canvas
70+
const video = document.createElement('video');
71+
video.muted = true;
72+
video.playsInline = true;
73+
video.style.display = 'none';
74+
video.srcObject = stream;
75+
document.body.appendChild(video);
76+
videoRef.current = video;
77+
const canvas = document.createElement('canvas');
78+
canvas.width = VIDEO_SIZE;
79+
canvas.height = VIDEO_SIZE;
80+
canvasRef.current = canvas;
81+
// 等待首帧就绪后开始采样(loadeddata → play → 定时采样)
82+
await new Promise<void>((resolve) => {
83+
const onReady = () => {
84+
video.removeEventListener('loadeddata', onReady);
85+
resolve();
86+
};
87+
video.addEventListener('loadeddata', onReady);
88+
// 兜底:2s 未就绪也继续(静默)
89+
setTimeout(resolve, 2000);
90+
});
91+
if (cancelled) return;
92+
await video.play().catch(() => {});
6693
await sample();
6794
interval = setInterval(sample, SAMPLE_INTERVAL_MS);
6895
} catch {
@@ -74,6 +101,9 @@ export function useAmbientLight(enabled: boolean): number {
74101
if (interval) clearInterval(interval);
75102
streamRef.current?.getTracks().forEach((t) => t.stop());
76103
streamRef.current = null;
104+
videoRef.current?.remove();
105+
videoRef.current = null;
106+
canvasRef.current = null;
77107
};
78108
}, [enabled, sample]);
79109

0 commit comments

Comments
 (0)