@ray-js/ipc-player-integration 0.0.50-beta.3 → 0.0.50-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,6 +57,48 @@ export declare const QUERY_LENS_TIMEOUT = 3000;
57
57
  * 待 APP 提供 zoomFree 的「手势是否结束」参数后可改为由该参数触发。
58
58
  */
59
59
  export declare const GESTURE_SWITCH_DEBOUNCE = 500;
60
+ /**
61
+ * 切镜头到位后,补下发目标倍率(d > 1)的延时(ms)。
62
+ *
63
+ * 新镜头的流刚起来,立刻打 scaleMultiple 有被原生层吞掉的风险;这段延时里的中间态回读
64
+ * 由 settleApplyStore 挡住,读数不会先闪回镜头正倍数。归正倍数(d = 1)不走延时,与旧行为一致。
65
+ */
66
+ export declare const POST_SWITCH_APPLY_DELAY = 200;
67
+ /**
68
+ * 补差值下发后,等回读追上目标 d 的最长时间(ms)。
69
+ * 超时解除中间态屏蔽 —— 原生层没按目标值回读时(量化、失败),读数不能被永久冻住。
70
+ */
71
+ export declare const SETTLE_APPLY_TIMEOUT = 3000;
72
+ /**
73
+ * 触底判定:连续多少帧 d ≈ 1 才认为「用户还在继续往下缩」。
74
+ *
75
+ * 取 2:第一帧是「正常缩到该镜头的光学正倍数」,从第二帧起 d 已经夹在协议下限动不了,
76
+ * 却仍有回读推上来,说明手指还在往里收。取 1 会把正常的「缩到正倍数」误判成越界。
77
+ */
78
+ export declare const FLOOR_HIT_COUNT = 2;
79
+ /** 触底计数的有效窗口(ms):两帧间隔超过它就不算连续,计数重置 */
80
+ export declare const FLOOR_HIT_WINDOW = 1200;
81
+ /**
82
+ * 切镜头收尾后的触底静默期(ms)。
83
+ * 收尾自身的 emit(setScaleMultipleEvent, 1.0) 会引发一到两帧 d = 1 回读,
84
+ * 不挡掉就会被算进触底计数,刚切上去又被切回来。
85
+ */
86
+ export declare const FLOOR_HIT_SETTLE_GUARD = 800;
87
+ /** 相邻两次「触底自动下切镜头」的冷却(ms):一次长手势不该连着切两级 */
88
+ export declare const DOWN_SWITCH_COOLDOWN = 800;
89
+ /**
90
+ * 同一次自动切镜头的跨实例去重窗口(ms)。
91
+ * Entry / Dock 各跑一份 useLensZoom,都订阅 zoomLevelChange,会同时判出同一次切换 ——
92
+ * 不去重就是两发 switchChannel。
93
+ */
94
+ export declare const SWITCH_DEDUPE_WINDOW = 300;
95
+ /**
96
+ * 转盘拖动跨镜头临界后,手停住多久(ms)就地切镜头。
97
+ *
98
+ * 逐帧切不行(switchChannel 是 P2P,几百毫秒级,会把通道打爆),只等松手也不行
99
+ * (拖过界的一路上画面是「冻」的)。折中:过界后手停住这么久就切,画面跟着拖动走。
100
+ */
101
+ export declare const DRAG_CROSS_SWITCH_DELAY = 600;
60
102
  /** dock 层级,需高于四角容器的 z-index: 100 */
61
103
  export declare const ZOOM_DOCK_Z_INDEX = 120;
62
104
  /**
@@ -72,6 +72,56 @@ export const QUERY_LENS_TIMEOUT = 3000;
72
72
  */
73
73
  export const GESTURE_SWITCH_DEBOUNCE = 500;
74
74
 
75
+ /**
76
+ * 切镜头到位后,补下发目标倍率(d > 1)的延时(ms)。
77
+ *
78
+ * 新镜头的流刚起来,立刻打 scaleMultiple 有被原生层吞掉的风险;这段延时里的中间态回读
79
+ * 由 settleApplyStore 挡住,读数不会先闪回镜头正倍数。归正倍数(d = 1)不走延时,与旧行为一致。
80
+ */
81
+ export const POST_SWITCH_APPLY_DELAY = 200;
82
+
83
+ /**
84
+ * 补差值下发后,等回读追上目标 d 的最长时间(ms)。
85
+ * 超时解除中间态屏蔽 —— 原生层没按目标值回读时(量化、失败),读数不能被永久冻住。
86
+ */
87
+ export const SETTLE_APPLY_TIMEOUT = 3000;
88
+
89
+ /**
90
+ * 触底判定:连续多少帧 d ≈ 1 才认为「用户还在继续往下缩」。
91
+ *
92
+ * 取 2:第一帧是「正常缩到该镜头的光学正倍数」,从第二帧起 d 已经夹在协议下限动不了,
93
+ * 却仍有回读推上来,说明手指还在往里收。取 1 会把正常的「缩到正倍数」误判成越界。
94
+ */
95
+ export const FLOOR_HIT_COUNT = 2;
96
+
97
+ /** 触底计数的有效窗口(ms):两帧间隔超过它就不算连续,计数重置 */
98
+ export const FLOOR_HIT_WINDOW = 1200;
99
+
100
+ /**
101
+ * 切镜头收尾后的触底静默期(ms)。
102
+ * 收尾自身的 emit(setScaleMultipleEvent, 1.0) 会引发一到两帧 d = 1 回读,
103
+ * 不挡掉就会被算进触底计数,刚切上去又被切回来。
104
+ */
105
+ export const FLOOR_HIT_SETTLE_GUARD = 800;
106
+
107
+ /** 相邻两次「触底自动下切镜头」的冷却(ms):一次长手势不该连着切两级 */
108
+ export const DOWN_SWITCH_COOLDOWN = 800;
109
+
110
+ /**
111
+ * 同一次自动切镜头的跨实例去重窗口(ms)。
112
+ * Entry / Dock 各跑一份 useLensZoom,都订阅 zoomLevelChange,会同时判出同一次切换 ——
113
+ * 不去重就是两发 switchChannel。
114
+ */
115
+ export const SWITCH_DEDUPE_WINDOW = 300;
116
+
117
+ /**
118
+ * 转盘拖动跨镜头临界后,手停住多久(ms)就地切镜头。
119
+ *
120
+ * 逐帧切不行(switchChannel 是 P2P,几百毫秒级,会把通道打爆),只等松手也不行
121
+ * (拖过界的一路上画面是「冻」的)。折中:过界后手停住这么久就切,画面跟着拖动走。
122
+ */
123
+ export const DRAG_CROSS_SWITCH_DELAY = 600;
124
+
75
125
  /** dock 层级,需高于四角容器的 z-index: 100 */
76
126
  export const ZOOM_DOCK_Z_INDEX = 120;
77
127
 
@@ -33,8 +33,16 @@ export declare const toleranceOf: (skill: ZoomSkill, index: number) => number;
33
33
  export declare const DEFAULT_STEP = 0.1;
34
34
  /**
35
35
  * 画面倍率 → 目标镜头下标。
36
- * 容差取各镜头自己的 step;同时命中多个区间时取大镜头,避免临界处反复横跳。
37
- * 代价:临界带会被判到大镜头,画面至多向上吸附一个 step。
36
+ *
37
+ * **严格命中优先**:半开区间 [lo, hi),最后一个镜头取闭区间 —— 2.9 判短焦、3.0 判长焦。
38
+ * 临界点本身仍归大镜头(3.0 是长焦的 basicRatio),但临界点**以下**不再被吸上去。
39
+ *
40
+ * 曾用「双向 ±step 容差 + 同时命中取大镜头」,那会让 [2.9, 3.0) 整条带子粘在长焦上:
41
+ * 转盘拖到 2.9 松手时 matchLens 判回长焦、不切镜头,而长焦画面最小只能到 3.0,
42
+ * 于是读数显示 2.9、画面停在 3.0,两者对不上。触底下切的目标值正好落在这条带子里,
43
+ * 所以这个吸附必须去掉。
44
+ *
45
+ * 容差退化为兜底:只在严格命中不到时用(浮点毛刺、越界值),此时才「同时命中取大镜头」。
38
46
  */
39
47
  export declare const matchLens: (skill: ZoomSkill, curM: number) => number;
40
48
  /** 由镜头 id 反查下标 */
@@ -68,20 +68,37 @@ export const DEFAULT_STEP = 0.1;
68
68
 
69
69
  /**
70
70
  * 画面倍率 → 目标镜头下标。
71
- * 容差取各镜头自己的 step;同时命中多个区间时取大镜头,避免临界处反复横跳。
72
- * 代价:临界带会被判到大镜头,画面至多向上吸附一个 step。
71
+ *
72
+ * **严格命中优先**:半开区间 [lo, hi),最后一个镜头取闭区间 —— 2.9 判短焦、3.0 判长焦。
73
+ * 临界点本身仍归大镜头(3.0 是长焦的 basicRatio),但临界点**以下**不再被吸上去。
74
+ *
75
+ * 曾用「双向 ±step 容差 + 同时命中取大镜头」,那会让 [2.9, 3.0) 整条带子粘在长焦上:
76
+ * 转盘拖到 2.9 松手时 matchLens 判回长焦、不切镜头,而长焦画面最小只能到 3.0,
77
+ * 于是读数显示 2.9、画面停在 3.0,两者对不上。触底下切的目标值正好落在这条带子里,
78
+ * 所以这个吸附必须去掉。
79
+ *
80
+ * 容差退化为兜底:只在严格命中不到时用(浮点毛刺、越界值),此时才「同时命中取大镜头」。
73
81
  */
74
82
  export const matchLens = (skill, curM) => {
75
- let hit = 0;
76
83
  const count = lensCount(skill);
84
+ if (count <= 0) return 0;
85
+ for (let i = 0; i < count; i++) {
86
+ const [lo, hi] = lensRange(skill, i);
87
+ const isLast = i === count - 1;
88
+ if (curM >= lo && (isLast ? curM <= hi : curM < hi)) return i;
89
+ }
90
+
91
+ // 严格命中不到:用各镜头自己的 step 作容差吸附,同时命中取大镜头
92
+ let hit = -1;
77
93
  for (let i = 0; i < count; i++) {
78
94
  const [lo, hi] = lensRange(skill, i);
79
95
  const tolerance = toleranceOf(skill, i);
80
- if (curM >= lo - tolerance && curM <= hi + tolerance) {
81
- hit = i;
82
- }
96
+ if (curM >= lo - tolerance && curM <= hi + tolerance) hit = i;
83
97
  }
84
- return hit;
98
+ if (hit >= 0) return hit;
99
+
100
+ // 彻底在倍率域之外:夹到最近的一端
101
+ return curM < lensRange(skill, 0)[0] ? 0 : count - 1;
85
102
  };
86
103
 
87
104
  /** 由镜头 id 反查下标 */
@@ -167,16 +167,20 @@ describe('镜头匹配', () => {
167
167
  expect(matchLens(DOUBLE, 4.0)).toBe(1);
168
168
  expect(matchLens(DOUBLE, 6.0)).toBe(1);
169
169
  });
170
- it('临界点归大镜头,避免横跳', () => {
171
- // 3.0 同时落在 lens0 [1,3] lens1 [3,6],取 basicRatio 大者
170
+ it('临界点本身归大镜头,避免横跳', () => {
171
+ // 半开区间 [lo, hi):3.0 lens1 的下界,归长焦
172
172
  expect(matchLens(DOUBLE, 3.0)).toBe(1);
173
173
  });
174
- it('容差取镜头 step(0.1):2.9 起进长焦,2.8 仍在短焦', () => {
175
- expect(matchLens(DOUBLE, 2.9)).toBe(1); // 2.9 lens1 [2.9, 6.1] 容差内 → 归大镜头
176
- expect(matchLens(DOUBLE, 2.8)).toBe(0); // 超出 lens1 容差下界 → 短焦
174
+ it('临界点以下不再被吸到大镜头:2.9 判短焦', () => {
175
+ // 旧实现双向 ±step 容差会让 [2.9, 3.0) 整条带子粘在长焦上,于是
176
+ // 转盘拖到 2.9 松手时不切镜头、画面被夹在 3.0,读数与画面对不上。
177
+ // 触底下切的落位(上限 − 1 step)正好在这条带子里,必须判成短焦。
178
+ expect(matchLens(DOUBLE, 2.9)).toBe(0);
179
+ expect(matchLens(DOUBLE, 2.99)).toBe(0);
177
180
  expect(matchLens(DOUBLE, 3.1)).toBe(1);
178
181
  });
179
- it('容差随各镜头 step 变化,未声明 step 时用缺省 0.1', () => {
182
+ it('容差退化为兜底:只在严格命中不到(越界)时吸附', () => {
183
+ // 区间内一律严格命中,step 多粗都不影响归属
180
184
  const coarse = {
181
185
  cnt: 2,
182
186
  thresholds: [1.0, 3.0, 6.0],
@@ -194,7 +198,7 @@ describe('镜头匹配', () => {
194
198
  ]
195
199
  };
196
200
  expect(toleranceOf(coarse, 1)).toBe(0.5);
197
- expect(matchLens(coarse, 2.6)).toBe(1); // 落在 lens1 的 [2.5, 6.5] 内
201
+ expect(matchLens(coarse, 2.6)).toBe(0);
198
202
  expect(matchLens(coarse, 2.4)).toBe(0);
199
203
  const noStep = {
200
204
  cnt: 2,
@@ -210,14 +214,22 @@ describe('镜头匹配', () => {
210
214
  }]
211
215
  };
212
216
  expect(toleranceOf(noStep, 1)).toBe(DEFAULT_MATCH_TOLERANCE);
213
- expect(matchLens(noStep, 2.9)).toBe(1);
217
+ expect(matchLens(noStep, 2.9)).toBe(0);
214
218
  expect(matchLens(noStep, 2.8)).toBe(0);
219
+
220
+ // 越界值:严格命中不到,用容差吸到相邻镜头
221
+ expect(matchLens(DOUBLE, 6.05)).toBe(1);
222
+ expect(matchLens(DOUBLE, 0.95)).toBe(0);
223
+ // 彻底在域外:夹到最近的一端
224
+ expect(matchLens(DOUBLE, 0.1)).toBe(0);
225
+ expect(matchLens(DOUBLE, 99)).toBe(1);
215
226
  });
216
227
  it('三目:跨两次临界点', () => {
217
228
  expect(matchLens(TRIPLE, 1.5)).toBe(0);
218
229
  expect(matchLens(TRIPLE, 4.0)).toBe(1);
219
230
  expect(matchLens(TRIPLE, 9.0)).toBe(2);
220
231
  expect(matchLens(TRIPLE, 6.0)).toBe(2); // 临界归大
232
+ expect(matchLens(TRIPLE, 5.9)).toBe(1); // 临界以下留在中焦
221
233
  });
222
234
  it('镜头 id 反查下标', () => {
223
235
  expect(indexOfLensId(DOUBLE, 1)).toBe(1);
@@ -6,7 +6,7 @@ import { useDpState } from '../../hooks/useDpState';
6
6
  import { setScaleMultipleEvent, zoomLevelChange } from '../../ui/constant';
7
7
  import { MOCK_ZOOM_SKILL, MULTI_ZOOM_DP_CODE, dumpChannelApis, isChannelChangeSupported, isZoomSkillValid, lensCount, parseZoomSkill, queryCurrentLens, showSmallMonitor, subscribeChannelChange, switchLens } from './lensSkill';
8
8
  import { DEFAULT_STEP, D_MIN, buildMarks, buildTickValues, indexOfLensId, lensRange, matchLens, maxScaleMultiple, toDisplayZoom, toScaleMultiple, zoomBounds } from './lensModel';
9
- import { CHANNEL_SETTLE_TIMEOUT, D_MATCH_EPS, GESTURE_SWITCH_DEBOUNCE, SWITCH_LENS_TIMEOUT, ZOOM_PREVIEW_THROTTLE, zoomTurntableLensChange, zoomTurntableSwitching, zoomTurntableZoomChange } from './constants';
9
+ import { CHANNEL_SETTLE_TIMEOUT, DOWN_SWITCH_COOLDOWN, DRAG_CROSS_SWITCH_DELAY, D_MATCH_EPS, FLOOR_HIT_COUNT, FLOOR_HIT_SETTLE_GUARD, FLOOR_HIT_WINDOW, GESTURE_SWITCH_DEBOUNCE, POST_SWITCH_APPLY_DELAY, SETTLE_APPLY_TIMEOUT, SWITCH_DEDUPE_WINDOW, SWITCH_LENS_TIMEOUT, ZOOM_PREVIEW_THROTTLE, zoomTurntableLensChange, zoomTurntableSwitching, zoomTurntableZoomChange } from './constants';
10
10
  /**
11
11
  * 内部调试开关:置 true 时用 MOCK_ZOOM_SKILL 顶替 ipc_multi_zoom 的上报值。
12
12
  *
@@ -42,6 +42,37 @@ const pendingSwitchStore = new Map();
42
42
  */
43
43
  const smallMonitorStore = new Map();
44
44
 
45
+ /**
46
+ * 切镜头收尾后正在补下发的目标 d(按 devId)。
47
+ *
48
+ * 收尾时读数 / tab 已经按目标值落地,画面却要等 setScaleMultiple 走完才到位;
49
+ * 中间原生层会先把画面复位到 1 倍并回读一帧。不挡掉的话读数会先闪回镜头正倍数再跳到目标值。
50
+ */
51
+ const settleApplyStore = new Map();
52
+
53
+ /** 触底计数(按 devId):画面已夹在 d = 1、用户还在继续缩小的连续帧数 */
54
+ const floorHitStore = new Map();
55
+
56
+ /** 最近一次切镜头收尾的时间(按 devId):收尾自身的 d = 1 回读不算触底 */
57
+ const settledAtStore = new Map();
58
+
59
+ /** 最近一次「触底自动下切」的时间(按 devId):冷却用 */
60
+ const downSwitchAtStore = new Map();
61
+
62
+ /** 最近一次切镜头下发(按 devId):Entry / Dock 两份 hook 判出同一次切换时去重 */
63
+ const switchReqStore = new Map();
64
+
65
+ /**
66
+ * 是否有转盘正在被拖动(按 devId)。
67
+ *
68
+ * 组件内的 draggingRef 只挡自己那份的回读,管不到别的实例:拖动中 previewZoom 会把
69
+ * 夹住的 d 连续下发,回读到 Entry 那份就成了「一直在推 d = 1」,正好被触底判定当成
70
+ * 「用户还在往下缩」,于是拖动落地和触底下切会同时切镜头。拖动路径自带落地逻辑,
71
+ * 手势那套自动切镜头必须整体让路,所以这个标记要跨实例共享。
72
+ */
73
+ const draggingStore = new Map();
74
+ const isDraggingAnywhere = devId => draggingStore.get(devId) === true;
75
+
45
76
  /** 自增序号:连着切到同一个镜头时,靠它认出「pending 还是不是我这次的」 */
46
77
  let switchSeq = 0;
47
78
  /** 是否正在切镜头(屏蔽回读用) */
@@ -55,6 +86,46 @@ const takePendingSwitch = devId => {
55
86
  return pending;
56
87
  };
57
88
 
89
+ /**
90
+ * 补差值下发期间的中间态回读要丢掉。
91
+ *
92
+ * 只丢「d ≈ 1」这一种 —— 那是原生层切完通道后画面自己的复位帧。ui.tsx 里
93
+ * 「先对齐 actual 再设 target」产生的那一帧已被它自己的 alignPending 抑制,不会到这儿。
94
+ * 收到任何非复位值就认为补差值已落地,解除屏蔽。
95
+ */
96
+ const shouldDropSettleEcho = (devId, d) => {
97
+ const applying = settleApplyStore.get(devId);
98
+ if (!applying) return false;
99
+ if (Date.now() > applying.expireAt) {
100
+ // 原生层没按目标值回读(量化 / 下发失败),不能把读数永久冻住
101
+ console.warn('[zoomTurntable] 补差值回读超时,解除中间态屏蔽', applying.d);
102
+ settleApplyStore.delete(devId);
103
+ return false;
104
+ }
105
+ if (Math.abs(d - D_MIN) <= D_MATCH_EPS) return true;
106
+ settleApplyStore.delete(devId);
107
+ return false;
108
+ };
109
+
110
+ /**
111
+ * 抢一次切镜头的下发权。
112
+ * Entry / Dock 都订阅 zoomLevelChange,同一帧回读会让两份 hook 判出同一次切换;
113
+ * 不去重就是两发 switchChannel(第二发还会把第一发的 pending 作废,读数更乱)。
114
+ */
115
+ const claimSwitch = (devId, index) => {
116
+ const last = switchReqStore.get(devId);
117
+ const now = Date.now();
118
+ if (last && last.index === index && now - last.at < SWITCH_DEDUPE_WINDOW) {
119
+ console.log('[zoomTurntable] 同一次切镜头已由另一实例下发,跳过', index);
120
+ return false;
121
+ }
122
+ switchReqStore.set(devId, {
123
+ index,
124
+ at: now
125
+ });
126
+ return true;
127
+ };
128
+
58
129
  /**
59
130
  * 焦距能力与双层缩放状态机。
60
131
  * zoomSkill / zoomLensIndex 存在 ctx 共享 atom 上,Entry / Dock / RealTimeMagnification 读同一份。
@@ -101,6 +172,19 @@ export const useLensZoom = options => {
101
172
  const gestureTimerRef = useRef();
102
173
  /** 拖动中连续下发的节流时间戳 */
103
174
  const previewAtRef = useRef(0);
175
+ /** 触底下切的 trailing 防抖(等手指停下再切,免得和正在进行的手势打架) */
176
+ const downTimerRef = useRef();
177
+ /** 拖动跨界后「手停住」的 trailing 防抖 */
178
+ const dragCrossTimerRef = useRef();
179
+
180
+ /**
181
+ * 自动切镜头(手势触底 / 跨临界)的驱动方:只由能力加载方(Entry)负责。
182
+ *
183
+ * Entry / Dock 各跑一份 hook,都订阅 zoomLevelChange。触底判定是**按帧计数**的,
184
+ * 两份都计数会让阈值形同减半;Entry 常驻(转盘展开时它只是被 CSS 藏起来,没卸载),
185
+ * 拿它当唯一驱动方最稳。拖动路径反过来只有 Dock 有,各管一段互不重叠。
186
+ */
187
+ const isAutoSwitchDriver = !skipLoad;
104
188
  const lensIndexRef = useRef(zoomLensIndex);
105
189
  lensIndexRef.current = zoomLensIndex;
106
190
  const skillRef = useRef(zoomSkill);
@@ -238,42 +322,79 @@ export const useLensZoom = options => {
238
322
  });
239
323
  const beginDrag = useMemoizedFn(value => {
240
324
  draggingRef.current = true;
325
+ draggingStore.set(devId, true);
241
326
  setCurM(value);
242
- previewZoom(value);
327
+ // 切镜头进行中(拖动跨界会在手指还按着时切):画面归收尾流程管,这里只更新读数
328
+ if (!isSwitching(devId)) previewZoom(value);
329
+ scheduleDragCross(value);
243
330
  });
244
331
 
245
332
  /**
246
- * 镜头已切到 index 后的收尾:画面比例主动归 1 倍,读数即该镜头的 basicRatio。
333
+ * 镜头已切到 index 后的收尾:把画面落到目标倍率上(**补差值**),读数与 tab 同步。
334
+ *
247
335
  * 正常路径由 channelChange 触发(等设备真正切完再动画面,中间态更干净);
248
336
  * 宿主不支持该事件时,退回 switchChannel 成功即收尾。
337
+ *
338
+ * @param targetM 目标画面绝对倍率。本端下发的切换都会带(从 pendingSwitch 取),
339
+ * 落位就是用户实际要的那个值:转盘拖到 2.5 就落 2.5、手势停在 3.5 就落 3.5、
340
+ * 触底下切落「上一镜头上限 − 1 step」。**不带**时才归该镜头的光学正倍数 ——
341
+ * 只有外部来源(设备侧 / 其他端切镜头)走这条,那时本端没有目标值可言。
342
+ *
343
+ * 早期设计是「切镜头一律归 1 倍、不补差值」,那会让下切必然掉到新镜头的 basicRatio:
344
+ * 从 3.0 往下拖到 2.5,切到短焦后画面直接变成 1.0,跨度大到像是坏了。
249
345
  */
250
- const settleLensSwitched = useMemoizedFn(index => {
251
- var _skillRef$current;
252
- const lens = (_skillRef$current = skillRef.current) === null || _skillRef$current === void 0 || (_skillRef$current = _skillRef$current.lenses) === null || _skillRef$current === void 0 ? void 0 : _skillRef$current[index];
253
- if (!lens) return;
254
- console.log('[scaleMultiple] zoomTurntable emit setScaleMultipleEvent', D_MIN, `lensIndex=${index}`, `lastD=${getLastD(devId)}`);
255
- event.emit(setScaleMultipleEvent, D_MIN);
256
- syncToLens(index, D_MIN);
346
+ const settleLensSwitched = useMemoizedFn((index, targetM) => {
347
+ var _skill$lenses;
348
+ const skill = skillRef.current;
349
+ const lens = skill === null || skill === void 0 || (_skill$lenses = skill.lenses) === null || _skill$lenses === void 0 ? void 0 : _skill$lenses[index];
350
+ if (!skill || !lens) return;
351
+ // 目标值夹进新镜头区间:跨界落点可能略微越界(容差、浮点毛刺)
352
+ const [lo, hi] = lensRange(skill, index);
353
+ const finalM = targetM == null ? lens.basicRatio : Math.min(hi, Math.max(lo, targetM));
354
+ const d = toScaleMultiple(skill, index, finalM);
355
+ const isBase = d <= D_MIN + D_MATCH_EPS;
356
+ console.log('[scaleMultiple] zoomTurntable 切镜头收尾', `lensIndex=${index}`, `finalM=${finalM.toFixed(2)}`, `d=${d.toFixed(3)}`, isBase ? '(归正倍数)' : '(补差值)', `lastD=${getLastD(devId)}`);
357
+
358
+ // 状态先落地:读数 / tab 立刻对齐,不等画面下发走完 P2P
359
+ syncToLens(index, d);
257
360
  markSwitching(-1);
258
- // 切镜头即回到该镜头的光学正倍数,小窗关掉(onChannelChange / 超时兜底都会走到这里)
259
- applySmallMonitor(false);
361
+ settledAtStore.set(devId, Date.now());
362
+ floorHitStore.delete(devId);
363
+ // 小窗跟最终 d 走:补差值后 d > 1(画面不在正倍数上),小窗该开着 ——
364
+ // 旧实现无条件 applySmallMonitor(false),补差值后就与画面状态不符了
365
+ syncSmallMonitorByD(d);
260
366
  event.emit(zoomTurntableLensChange, {
261
367
  lensId: lens.id,
262
368
  lensIndex: index,
263
- value: lens.basicRatio
369
+ value: finalM
264
370
  });
265
371
  event.emit(zoomTurntableZoomChange, {
266
- curM: lens.basicRatio,
267
- d: D_MIN,
372
+ curM: finalM,
373
+ d,
268
374
  lensIndex: index
269
375
  });
376
+ if (isBase) {
377
+ // 归正倍数:与旧行为一致,立即下发
378
+ event.emit(setScaleMultipleEvent, D_MIN);
379
+ return;
380
+ }
381
+ // 补差值:新流刚起来,立刻打 scaleMultiple 有被吞的风险,延一拍;
382
+ // 这段窗口内的复位帧回读由 settleApplyStore 挡掉
383
+ settleApplyStore.set(devId, {
384
+ d,
385
+ expireAt: Date.now() + SETTLE_APPLY_TIMEOUT
386
+ });
387
+ setTimeout(() => {
388
+ console.log('[scaleMultiple] zoomTurntable 补差值下发', d, `lensIndex=${index}`);
389
+ event.emit(setScaleMultipleEvent, d);
390
+ }, POST_SWITCH_APPLY_DELAY);
270
391
  });
271
392
 
272
393
  /** 把目标倍率落到指定镜头上:同镜头内下发本地缩放,跨镜头则切物理镜头并归 1 倍 */
273
394
  const applyToLens = useMemoizedFn(async (nextIndex, targetM) => {
274
- var _skill$lenses;
395
+ var _skill$lenses2;
275
396
  const skill = skillRef.current;
276
- if (!(skill !== null && skill !== void 0 && (_skill$lenses = skill.lenses) !== null && _skill$lenses !== void 0 && _skill$lenses[nextIndex])) return;
397
+ if (!(skill !== null && skill !== void 0 && (_skill$lenses2 = skill.lenses) !== null && _skill$lenses2 !== void 0 && _skill$lenses2[nextIndex])) return;
277
398
  setCurM(targetM);
278
399
  const prevIndex = lensIndexRef.current;
279
400
  if (nextIndex === prevIndex) {
@@ -290,6 +411,9 @@ export const useLensZoom = options => {
290
411
  return;
291
412
  }
292
413
 
414
+ // Entry / Dock 同一帧判出同一次切换时只放一次过(读数已在上面各自更新过)
415
+ if (!claimSwitch(devId, nextIndex)) return;
416
+
293
417
  // 切物理镜头:从下发到 channelChange 回来这段屏蔽回读
294
418
  console.log('[zoomTurntable] 切换镜头', `${prevIndex} -> ${nextIndex}`, `channel=${skill.lenses[nextIndex].id}`, `targetM=${targetM.toFixed(2)}`);
295
419
  takePendingSwitch(devId); // 上一次没结算完的直接作废,以最新这次为准
@@ -305,6 +429,7 @@ export const useLensZoom = options => {
305
429
  pendingSwitchStore.set(devId, {
306
430
  seq,
307
431
  index: nextIndex,
432
+ targetM,
308
433
  timer: setTimeout(() => {
309
434
  pendingSwitchStore.delete(devId);
310
435
  markSwitching(-1);
@@ -328,7 +453,7 @@ export const useLensZoom = options => {
328
453
  // 那时设备已经真的切过去了,画面不会先跳一下再稳定
329
454
  if (!isChannelChangeSupported()) {
330
455
  takePendingSwitch(devId);
331
- settleLensSwitched(nextIndex);
456
+ settleLensSwitched(nextIndex, targetM);
332
457
  return;
333
458
  }
334
459
 
@@ -345,10 +470,49 @@ export const useLensZoom = options => {
345
470
  // 有到达但没有「镜头切换到位」→ 被 deviceId 或 channel 校验挡掉了
346
471
  console.warn('[zoomTurntable] 等 channelChange 超时,按下发结果收尾', `${prevIndex} -> ${nextIndex}`, `channel=${skill.lenses[nextIndex].id}`, `${CHANNEL_SETTLE_TIMEOUT}ms`, `onChannelChange 可用=${isChannelChangeSupported()}`);
347
472
  dumpChannelApis();
348
- settleLensSwitched(nextIndex);
473
+ settleLensSwitched(nextIndex, targetM);
349
474
  }, CHANNEL_SETTLE_TIMEOUT);
350
475
  });
351
476
 
477
+ /**
478
+ * 拖动跨镜头临界:过界后**手停住** DRAG_CROSS_SWITCH_DELAY 就地切镜头,画面跟着拖动走。
479
+ *
480
+ * previewZoom 把下发值夹在当前镜头区间内,所以拖过界之后画面是「冻」住的 ——
481
+ * 只等松手才切的话,从 3.0 往下拖到 2.5 的一路上画面纹丝不动,手感像是没响应。
482
+ * 逐帧切也不行:switchChannel 是 P2P、几百毫秒级,逐帧下发会把通道打爆。
483
+ * 折中成 trailing 防抖:值一直在变就一直不切,停住才切。
484
+ *
485
+ * 切换期间 draggingRef 保持 true(见 onChannelChange 里的说明),松手后 applyZoom
486
+ * 再按最终值落一次 —— 那时镜头通常已经对了,走的是同镜头内下发 d 的分支。
487
+ */
488
+ const scheduleDragCross = useMemoizedFn(value => {
489
+ const clearPending = () => {
490
+ if (dragCrossTimerRef.current) {
491
+ clearTimeout(dragCrossTimerRef.current);
492
+ dragCrossTimerRef.current = undefined;
493
+ }
494
+ };
495
+ const skill = skillRef.current;
496
+ if (!skill) return;
497
+ // 回到当前镜头区间内 / 已在切换中:撤掉待切
498
+ if (matchLens(skill, value) === lensIndexRef.current || isSwitching(devId)) {
499
+ clearPending();
500
+ return;
501
+ }
502
+ clearPending();
503
+ dragCrossTimerRef.current = setTimeout(() => {
504
+ dragCrossTimerRef.current = undefined;
505
+ // 已松手 → 交给 applyZoom 落地,不在这儿抢
506
+ if (!draggingRef.current || isSwitching(devId)) return;
507
+ const cur = skillRef.current;
508
+ if (!cur) return;
509
+ const nextIndex = matchLens(cur, value);
510
+ if (nextIndex === lensIndexRef.current) return;
511
+ console.log('[zoomTurntable] 拖动跨界停留,就地切镜头', `${lensIndexRef.current} -> ${nextIndex}`, `targetM=${value.toFixed(2)}`);
512
+ applyToLens(nextIndex, value);
513
+ }, DRAG_CROSS_SWITCH_DELAY);
514
+ });
515
+
352
516
  /**
353
517
  * 拖动状态兜底复位。
354
518
  *
@@ -359,11 +523,22 @@ export const useLensZoom = options => {
359
523
  */
360
524
  const endDrag = useMemoizedFn(() => {
361
525
  draggingRef.current = false;
526
+ draggingStore.delete(devId);
527
+ if (dragCrossTimerRef.current) {
528
+ clearTimeout(dragCrossTimerRef.current);
529
+ dragCrossTimerRef.current = undefined;
530
+ }
362
531
  });
363
532
 
364
533
  /** 拖动结束:按倍率匹配镜头后落地 */
365
534
  const applyZoom = useMemoizedFn(async targetM => {
366
535
  draggingRef.current = false;
536
+ draggingStore.delete(devId);
537
+ // 松手即是最终值,待切的那次作废,直接按最终值走
538
+ if (dragCrossTimerRef.current) {
539
+ clearTimeout(dragCrossTimerRef.current);
540
+ dragCrossTimerRef.current = undefined;
541
+ }
367
542
  const skill = skillRef.current;
368
543
  if (!skill) return;
369
544
  await applyToLens(matchLens(skill, targetM), targetM);
@@ -371,11 +546,12 @@ export const useLensZoom = options => {
371
546
 
372
547
  /** 点击倍率 tab:直接切到该镜头并归位到其光学倍数;不走 matchLens,免得容差判到相邻镜头 */
373
548
  const switchToLens = useMemoizedFn(async index => {
374
- var _skill$lenses2;
549
+ var _skill$lenses3;
375
550
  const skill = skillRef.current;
376
- const lens = skill === null || skill === void 0 || (_skill$lenses2 = skill.lenses) === null || _skill$lenses2 === void 0 ? void 0 : _skill$lenses2[index];
551
+ const lens = skill === null || skill === void 0 || (_skill$lenses3 = skill.lenses) === null || _skill$lenses3 === void 0 ? void 0 : _skill$lenses3[index];
377
552
  if (!lens) return;
378
553
  draggingRef.current = false;
554
+ draggingStore.delete(devId);
379
555
  await applyToLens(index, lens.basicRatio);
380
556
  });
381
557
 
@@ -406,9 +582,11 @@ export const useLensZoom = options => {
406
582
  return; // 外部来源的重复事件
407
583
  }
408
584
  console.log('[zoomTurntable] 镜头切换到位', `${lensIndexRef.current} -> ${index}`, pending ? '(本端下发)' : '(外部来源)');
409
- // 读数以设备为准,进行中的拖动作废
410
- draggingRef.current = false;
411
- settleLensSwitched(index);
585
+ // 只有外部来源(设备侧 / 其他端)才作废进行中的拖动 —— 读数得以设备为准。
586
+ // 本端下发的那次不能动 draggingRef:拖动跨界会在**手指还按着**的时候切镜头,
587
+ // 置 false 会放开回读屏蔽,回读帧和拖动帧互相打架,指针会抖。
588
+ if (!pending) draggingRef.current = false;
589
+ settleLensSwitched(index, pending === null || pending === void 0 ? void 0 : pending.targetM);
412
590
  });
413
591
  useEffect(() => {
414
592
  // 订没订上必须能从日志看出来:没订上时切镜头一定走超时回滚,两者表象一样
@@ -434,15 +612,94 @@ export const useLensZoom = options => {
434
612
  gestureTimerRef.current = setTimeout(() => {
435
613
  gestureTimerRef.current = undefined;
436
614
  const skill = skillRef.current;
437
- if (!skill || draggingRef.current || isSwitching(devId)) return;
615
+ if (!skill || draggingRef.current || isDraggingAnywhere(devId) || isSwitching(devId)) return;
438
616
  const nextIndex = matchLens(skill, value);
439
617
  if (nextIndex === lensIndexRef.current) return;
440
618
  console.log('[zoomTurntable] 手势跨临界点,切换镜头', `curM=${value.toFixed(2)}`, `${lensIndexRef.current} -> ${nextIndex}`);
441
619
  applyToLens(nextIndex, value);
442
620
  }, GESTURE_SWITCH_DEBOUNCE);
443
621
  });
622
+
623
+ /**
624
+ * 触底继续缩 → 往下切一个镜头(trailing 防抖,等手指停下再切)。
625
+ *
626
+ * 现象:在非首个镜头上把画面缩到该镜头的光学正倍数后(d = 1,协议下限),用户继续
627
+ * 双指往里收,播放器的 d 已经夹住动不了,画面纹丝不动,读数也停在 basicRatio。
628
+ * 此时 zoomLevelChange 仍然按帧推 d = 1 上来 —— 这就是唯一可用的信号
629
+ * (detail.scaleMode「自由缩放状态标识」实测不准,不能拿来判定)。
630
+ *
631
+ * 落位取「上一个镜头区间上限 − 1 step」(如 3.0 → 2.9):那正是画面能继续变小的第一格,
632
+ * 换算到短焦上是 d ≈ 3.85,用户接着往里收就能顺畅地一路缩到 1.0。
633
+ */
634
+ const scheduleDownSwitch = useMemoizedFn(() => {
635
+ var _downSwitchAtStore$ge;
636
+ if (Date.now() - ((_downSwitchAtStore$ge = downSwitchAtStore.get(devId)) !== null && _downSwitchAtStore$ge !== void 0 ? _downSwitchAtStore$ge : 0) < DOWN_SWITCH_COOLDOWN) return;
637
+ if (downTimerRef.current) clearTimeout(downTimerRef.current);
638
+ downTimerRef.current = setTimeout(() => {
639
+ var _skill$lenses$target$, _skill$lenses4;
640
+ downTimerRef.current = undefined;
641
+ const skill = skillRef.current;
642
+ const index = lensIndexRef.current;
643
+ if (!skill || index <= 0) return;
644
+ if (draggingRef.current || isDraggingAnywhere(devId) || isSwitching(devId)) return;
645
+ // 防抖这 500ms 里可能又被放大了,切之前再确认画面确实还夹在正倍数上
646
+ if (getLastD(devId) > D_MIN + D_MATCH_EPS) {
647
+ floorHitStore.delete(devId);
648
+ return;
649
+ }
650
+ const target = index - 1;
651
+ const [, upper] = lensRange(skill, target);
652
+ const stepOfTarget = (_skill$lenses$target$ = (_skill$lenses4 = skill.lenses) === null || _skill$lenses4 === void 0 || (_skill$lenses4 = _skill$lenses4[target]) === null || _skill$lenses4 === void 0 ? void 0 : _skill$lenses4.step) !== null && _skill$lenses$target$ !== void 0 ? _skill$lenses$target$ : DEFAULT_STEP;
653
+ const targetM = upper - stepOfTarget;
654
+ downSwitchAtStore.set(devId, Date.now());
655
+ floorHitStore.delete(devId);
656
+ console.log('[zoomTurntable] 触底继续缩,向下切镜头', `${index} -> ${target}`, `targetM=${targetM.toFixed(2)}`);
657
+ applyToLens(target, targetM);
658
+ }, GESTURE_SWITCH_DEBOUNCE);
659
+ });
660
+
661
+ /**
662
+ * 触底计数。返回 true 表示已排下切,本帧不再走上界 / 防抖那套判定。
663
+ * 只由驱动方计数 —— 两份 hook 都数会让阈值形同减半。
664
+ */
665
+ const handleFloorHit = useMemoizedFn(d => {
666
+ var _settledAtStore$get;
667
+ if (!isAutoSwitchDriver) return false;
668
+ // 拖动中:落地由 applyZoom / 跨界停留负责,触底那套整体让路
669
+ if (isDraggingAnywhere(devId)) {
670
+ floorHitStore.delete(devId);
671
+ return false;
672
+ }
673
+ // 不在正倍数上:正常缩放,清计数
674
+ if (d > D_MIN + D_MATCH_EPS) {
675
+ floorHitStore.delete(devId);
676
+ return false;
677
+ }
678
+ // 首个镜头已经是倍率域下界,再缩没有镜头可切
679
+ if (lensIndexRef.current <= 0) return false;
680
+ const now = Date.now();
681
+ // 收尾自身会引发一到两帧 d = 1 回读,静默期内不计数,否则刚切上去又被切回来
682
+ if (now - ((_settledAtStore$get = settledAtStore.get(devId)) !== null && _settledAtStore$get !== void 0 ? _settledAtStore$get : 0) < FLOOR_HIT_SETTLE_GUARD) return false;
683
+ const prev = floorHitStore.get(devId);
684
+ const count = prev && now - prev.lastAt <= FLOOR_HIT_WINDOW ? prev.count + 1 : 1;
685
+ floorHitStore.set(devId, {
686
+ count,
687
+ lastAt: now
688
+ });
689
+ // 每一帧都打:真机上「触底还缩不缩得动」全靠这条日志判断 ——
690
+ // 如果原生只在 d 真正变化时才推事件,这里会永远停在 count=1、下切一次都不会触发,
691
+ // 那就说明这条信号不成立,得改由 APP 侧补「手势下界」参数(协议 §4.3 第 5 条)
692
+ console.log('[zoomTurntable] 触底回读', `count=${count}/${FLOOR_HIT_COUNT}`, `lensIndex=${lensIndexRef.current}`);
693
+ // 第一帧是「正常缩到正倍数」,从第二帧起才是「夹住了还在缩」
694
+ if (count < FLOOR_HIT_COUNT) return false;
695
+ console.log('[zoomTurntable] 画面已夹在正倍数仍有缩放回读,排下切', `count=${count}`);
696
+ scheduleDownSwitch();
697
+ return true;
698
+ });
444
699
  const onZoomLevelChange = useMemoizedFn(d => {
445
700
  if (!(d > 0)) return;
701
+ // 补差值下发期间原生画面会先复位到 1 倍,那一帧不能让它把读数拽回镜头正倍数
702
+ if (shouldDropSettleEcho(devId, d)) return;
446
703
  setLastD(devId, d);
447
704
  // 切镜头途中的中间态不算数(收尾时统一关掉),拖动中画面还没动
448
705
  if (draggingRef.current || isSwitching(devId)) return;
@@ -458,6 +715,13 @@ export const useLensZoom = options => {
458
715
  lensIndex: lensIndexRef.current
459
716
  });
460
717
 
718
+ // 触底继续缩:往下切一个镜头(下界,与下面的上界对称)
719
+ if (handleFloorHit(d)) return;
720
+ if (!isAutoSwitchDriver) return;
721
+ // 拖动中:previewZoom 把下发值夹在当前镜头区间内,回读会一直贴着区间边界,
722
+ // 不让路的话「触到上界立即切」会和拖动跨界停留同时切镜头
723
+ if (isDraggingAnywhere(devId)) return;
724
+
461
725
  // 画面已经顶到当前镜头区间的上界(也就是下一个镜头的光学倍数,如 1x 镜头放到 3.0):
462
726
  // 这个边界值在归属上属于下一个镜头,必须立刻切过去。
463
727
  // 走 trailing 防抖的话,那 500ms 里 tab 会挂着「下一镜头的值」显示在当前镜头上。
@@ -484,6 +748,8 @@ export const useLensZoom = options => {
484
748
  useEffect(() => {
485
749
  return () => {
486
750
  if (gestureTimerRef.current) clearTimeout(gestureTimerRef.current);
751
+ if (downTimerRef.current) clearTimeout(downTimerRef.current);
752
+ if (dragCrossTimerRef.current) clearTimeout(dragCrossTimerRef.current);
487
753
  };
488
754
  }, []);
489
755
  const [min, max] = zoomSkill ? zoomBounds(zoomSkill) : [D_MIN, D_MIN];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ray-js/ipc-player-integration",
3
- "version": "0.0.50-beta.3",
3
+ "version": "0.0.50-beta.4",
4
4
  "description": "IPC 融合播放器",
5
5
  "main": "lib/index",
6
6
  "files": [