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

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.
@@ -14,7 +14,8 @@ export type InitPlayerWidgetsOptions = {
14
14
  showRealTimeMagnification?: boolean;
15
15
  /**
16
16
  * 允许展示焦距转盘入口(长按图标弹出转盘)。
17
- * 仅为功能开关;实际渲染还要 ipc_multi_zoom 解析出 cnt > 1,单镜头设备传 true 也不出现。
17
+ * 仅为功能开关;实际渲染还要宿主提供 ty.ipc.switchChannel、且 ipc_multi_zoom 解析出 cnt > 1
18
+ * 单镜头设备与老宿主上传 true 也不出现。
18
19
  */
19
20
  showZoomTurntable?: boolean;
20
21
  directionControlProps?: Partial<React.ComponentProps<typeof FullScreen>['directionControlProps']>;
@@ -53,7 +53,8 @@ export async function initPlayerWidgets(ctx, options) {
53
53
  }
54
54
  }
55
55
  // 焦距转盘入口:这里只放开占位,真正是否渲染由 widget 内部按
56
- // ipc_multi_zoom 上报值解析出的 cnt > 1 决定(单镜头设备不出现)
56
+ // 「宿主有 ty.ipc.switchChannel」+「ipc_multi_zoom 解析出 cnt > 1」决定
57
+ // (单镜头设备、不支持切镜头的老宿主都不出现)
57
58
  if (options.showZoomTurntable) {
58
59
  const zoomTurntableIndex = newDefaultBottomLeftContent.findIndex(item => item.id === 'ZoomTurntableEntry');
59
60
  if (zoomTurntableIndex !== -1) {
@@ -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);
@@ -56,6 +56,14 @@ export declare const isZoomSkillValid: (skill?: ZoomSkill | null) => boolean;
56
56
  * @param value DP 值;内部 mock 常量是对象、DP 上报是 string,两条来源共用同一校验链路
57
57
  */
58
58
  export declare const parseZoomSkill: (value?: unknown) => ZoomSkill | null;
59
+ /**
60
+ * 宿主是否支持切镜头下发。
61
+ *
62
+ * 转盘的每一种交互(点 tab 切镜头、拖转盘跨界、手势触底下切)最终都落到 switchChannel 上,
63
+ * 方法不存在时转盘画得出来却切不动,只会让用户以为坏了 —— 故整个入口不渲染
64
+ * (门控在 useLensZoom 的 multiLensReady 里,与 cnt > 1 同级)。
65
+ */
66
+ export declare const isSwitchChannelSupported: () => boolean;
59
67
  /**
60
68
  * 切换物理镜头。channel 取上报的子镜头 id 而非数组下标 —— 固件可上报非连续 id。
61
69
  * 返回 false(不支持 / 失败)时由 useLensZoom 回滚读数,无回调另有 SWITCH_LENS_TIMEOUT 兜底。
@@ -111,13 +111,34 @@ export const parseZoomSkill = value => {
111
111
 
112
112
  /** ty.ipc.switchChannel 的最小签名:ty 类型包尚未收录,本地声明避免调用点退化成 any */
113
113
 
114
+ /** 「宿主不支持 switchChannel」只提示一次:判定会被 render 反复调用,不去重会刷屏 */
115
+ let switchChannelMissingLogged = false;
116
+
117
+ /**
118
+ * 宿主是否支持切镜头下发。
119
+ *
120
+ * 转盘的每一种交互(点 tab 切镜头、拖转盘跨界、手势触底下切)最终都落到 switchChannel 上,
121
+ * 方法不存在时转盘画得出来却切不动,只会让用户以为坏了 —— 故整个入口不渲染
122
+ * (门控在 useLensZoom 的 multiLensReady 里,与 cnt > 1 同级)。
123
+ */
124
+ export const isSwitchChannelSupported = () => {
125
+ var _ty;
126
+ const supported = typeof ((_ty = ty) === null || _ty === void 0 || (_ty = _ty.ipc) === null || _ty === void 0 ? void 0 : _ty.switchChannel) === 'function';
127
+ if (!supported && !switchChannelMissingLogged) {
128
+ switchChannelMissingLogged = true;
129
+ console.warn('[zoomTurntable] 宿主不支持 ty.ipc.switchChannel,焦距转盘入口不展示');
130
+ dumpChannelApis();
131
+ }
132
+ return supported;
133
+ };
134
+
114
135
  /**
115
136
  * 切换物理镜头。channel 取上报的子镜头 id 而非数组下标 —— 固件可上报非连续 id。
116
137
  * 返回 false(不支持 / 失败)时由 useLensZoom 回滚读数,无回调另有 SWITCH_LENS_TIMEOUT 兜底。
117
138
  */
118
139
  export const switchLens = (devId, lens) => {
119
- var _ty;
120
- const switchChannel = (_ty = ty) === null || _ty === void 0 || (_ty = _ty.ipc) === null || _ty === void 0 ? void 0 : _ty.switchChannel;
140
+ var _ty2;
141
+ const switchChannel = (_ty2 = ty) === null || _ty2 === void 0 || (_ty2 = _ty2.ipc) === null || _ty2 === void 0 ? void 0 : _ty2.switchChannel;
121
142
  // 入参与回调结果都打出来:真机上「切没切成」只能靠这条链路判断
122
143
  const params = {
123
144
  deviceId: devId,
@@ -223,8 +244,8 @@ const pickChannel = payload => {
223
244
  * 返回 null(不支持 / 失败 / 超时)时调用方保持现状,不重置镜头。
224
245
  */
225
246
  export const queryCurrentLens = devId => {
226
- var _ty2;
227
- const queryCurrentChannel = (_ty2 = ty) === null || _ty2 === void 0 || (_ty2 = _ty2.ipc) === null || _ty2 === void 0 ? void 0 : _ty2.queryCurrentChannel;
247
+ var _ty3;
248
+ const queryCurrentChannel = (_ty3 = ty) === null || _ty3 === void 0 || (_ty3 = _ty3.ipc) === null || _ty3 === void 0 ? void 0 : _ty3.queryCurrentChannel;
228
249
  if (typeof queryCurrentChannel !== 'function') {
229
250
  console.warn('[zoomTurntable] 宿主不支持 ty.ipc.queryCurrentChannel');
230
251
  return Promise.resolve(null);
@@ -273,8 +294,8 @@ export const queryCurrentLens = devId => {
273
294
  * 切镜头的收尾(画面归 1 倍 + 刷读数)以该事件为准,不支持时只能退回「switchChannel 成功即收尾」。
274
295
  */
275
296
  export const isChannelChangeSupported = () => {
276
- var _ty3;
277
- return typeof ((_ty3 = ty) === null || _ty3 === void 0 || (_ty3 = _ty3.ipc) === null || _ty3 === void 0 ? void 0 : _ty3.onChannelChange) === 'function';
297
+ var _ty4;
298
+ return typeof ((_ty4 = ty) === null || _ty4 === void 0 || (_ty4 = _ty4.ipc) === null || _ty4 === void 0 ? void 0 : _ty4.onChannelChange) === 'function';
278
299
  };
279
300
 
280
301
  /**
@@ -282,8 +303,8 @@ export const isChannelChangeSupported = () => {
282
303
  * 真机上「事件为什么不来」第一步就是确认宿主到底提供了哪个名字。
283
304
  */
284
305
  export const dumpChannelApis = () => {
285
- var _ty4;
286
- const ipc = (_ty4 = ty) === null || _ty4 === void 0 ? void 0 : _ty4.ipc;
306
+ var _ty5;
307
+ const ipc = (_ty5 = ty) === null || _ty5 === void 0 ? void 0 : _ty5.ipc;
287
308
  if (!ipc) {
288
309
  console.warn('[zoomTurntable] ty.ipc 不存在', `typeof ty=${typeof ty}`);
289
310
  return;
@@ -321,9 +342,9 @@ export const subscribeChannelChange = (devId, handler) => {
321
342
  console.log('[zoomTurntable] 已订阅 ty.ipc.onChannelChange', devId);
322
343
  ty.ipc.onChannelChange(listener);
323
344
  return () => {
324
- var _ty5;
345
+ var _ty6;
325
346
  console.log('[zoomTurntable] 解绑 channelChange 监听', devId);
326
- if (typeof ((_ty5 = ty) === null || _ty5 === void 0 || (_ty5 = _ty5.ipc) === null || _ty5 === void 0 ? void 0 : _ty5.offChannelChange) === 'function') {
347
+ if (typeof ((_ty6 = ty) === null || _ty6 === void 0 || (_ty6 = _ty6.ipc) === null || _ty6 === void 0 ? void 0 : _ty6.offChannelChange) === 'function') {
327
348
  ty.ipc.offChannelChange(listener);
328
349
  } else {
329
350
  console.warn('[zoomTurntable] 宿主无 offChannelChange,监听无法解绑');
@@ -1,6 +1,6 @@
1
1
  import { ComponentConfigProps } from '../../interface';
2
2
  type Options = ComponentConfigProps & {
3
- /** 业务侧的功能开关;实际是否渲染仍由 ipc_multi_zoom 解析出的 cnt 决定 */
3
+ /** 业务侧的功能开关;实际是否渲染仍由宿主 switchChannel 能力 + ipc_multi_zoom cnt 决定 */
4
4
  showZoomTurntable?: boolean;
5
5
  /** 跳过能力加载:Entry 常驻负责加载,Dock 作为消费方传 true 避免重复解析 */
6
6
  skipLoad?: boolean;
@@ -1,12 +1,12 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useMemoizedFn } from 'ahooks';
3
3
  import { useStore } from '../../ctx/store';
4
4
  import { PlayState } from '../../interface';
5
5
  import { useDpState } from '../../hooks/useDpState';
6
6
  import { setScaleMultipleEvent, zoomLevelChange } from '../../ui/constant';
7
- import { MOCK_ZOOM_SKILL, MULTI_ZOOM_DP_CODE, dumpChannelApis, isChannelChangeSupported, isZoomSkillValid, lensCount, parseZoomSkill, queryCurrentLens, showSmallMonitor, subscribeChannelChange, switchLens } from './lensSkill';
7
+ import { MOCK_ZOOM_SKILL, MULTI_ZOOM_DP_CODE, dumpChannelApis, isChannelChangeSupported, isSwitchChannelSupported, 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);
@@ -126,9 +210,18 @@ export const useLensZoom = options => {
126
210
  };
127
211
  }, [event]);
128
212
 
213
+ // ---------------- 宿主能力 ----------------
214
+ /**
215
+ * 宿主是否支持 ty.ipc.switchChannel。宿主能力在运行期不会变,求一次即可 ——
216
+ * 放 useMemo 而不是 render 期直接调,免得 typeof 判定与首次告警日志跟着每次渲染跑。
217
+ *
218
+ * 转盘的每一种交互最终都落到这个方法上,它不存在时转盘切不动,故整个入口不展示。
219
+ */
220
+ const switchChannelSupported = useMemo(() => isSwitchChannelSupported(), []);
221
+
129
222
  // ---------------- 能力加载 ----------------
130
223
  useEffect(() => {
131
- if (skipLoad || !devId || !showZoomTurntable) return undefined;
224
+ if (skipLoad || !devId || !showZoomTurntable || !switchChannelSupported) return undefined;
132
225
  const raw = multiZoomDpValue;
133
226
  console.log('[zoomTurntable] ipc_multi_zoom 上报值', JSON.stringify(raw !== null && raw !== void 0 ? raw : null));
134
227
  const skill = parseZoomSkill(raw);
@@ -139,10 +232,14 @@ export const useLensZoom = options => {
139
232
  }
140
233
  setZoomSkill(skill);
141
234
  return undefined;
142
- }, [devId, showZoomTurntable, multiZoomDpValue, skipLoad]);
235
+ }, [devId, showZoomTurntable, switchChannelSupported, multiZoomDpValue, skipLoad]);
143
236
 
144
- /** 多镜头能力就绪:ipc_multi_zoom 解析出的 cnt > 1(单镜头设备不出现) */
145
- const multiLensReady = showZoomTurntable && lensCount(zoomSkill) > 1;
237
+ /**
238
+ * 多镜头能力就绪:宿主提供 ty.ipc.switchChannel,且 ipc_multi_zoom 解析出的 cnt > 1
239
+ * 两条缺一个入口都不出现 —— 单镜头设备没有可切的镜头,
240
+ * 老宿主则是切不动(见 isSwitchChannelSupported)。
241
+ */
242
+ const multiLensReady = showZoomTurntable && switchChannelSupported && lensCount(zoomSkill) > 1;
146
243
 
147
244
  // 手势缩放上限按当前镜头换算后下发给播放器:maxScaleMultiple 是 d 口径,
148
245
  // 直接用画面绝对上限(如 6.0)会让画面放到该镜头区间之外。切镜头后跟着更新。
@@ -238,42 +335,79 @@ export const useLensZoom = options => {
238
335
  });
239
336
  const beginDrag = useMemoizedFn(value => {
240
337
  draggingRef.current = true;
338
+ draggingStore.set(devId, true);
241
339
  setCurM(value);
242
- previewZoom(value);
340
+ // 切镜头进行中(拖动跨界会在手指还按着时切):画面归收尾流程管,这里只更新读数
341
+ if (!isSwitching(devId)) previewZoom(value);
342
+ scheduleDragCross(value);
243
343
  });
244
344
 
245
345
  /**
246
- * 镜头已切到 index 后的收尾:画面比例主动归 1 倍,读数即该镜头的 basicRatio。
346
+ * 镜头已切到 index 后的收尾:把画面落到目标倍率上(**补差值**),读数与 tab 同步。
347
+ *
247
348
  * 正常路径由 channelChange 触发(等设备真正切完再动画面,中间态更干净);
248
349
  * 宿主不支持该事件时,退回 switchChannel 成功即收尾。
350
+ *
351
+ * @param targetM 目标画面绝对倍率。本端下发的切换都会带(从 pendingSwitch 取),
352
+ * 落位就是用户实际要的那个值:转盘拖到 2.5 就落 2.5、手势停在 3.5 就落 3.5、
353
+ * 触底下切落「上一镜头上限 − 1 step」。**不带**时才归该镜头的光学正倍数 ——
354
+ * 只有外部来源(设备侧 / 其他端切镜头)走这条,那时本端没有目标值可言。
355
+ *
356
+ * 早期设计是「切镜头一律归 1 倍、不补差值」,那会让下切必然掉到新镜头的 basicRatio:
357
+ * 从 3.0 往下拖到 2.5,切到短焦后画面直接变成 1.0,跨度大到像是坏了。
249
358
  */
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);
359
+ const settleLensSwitched = useMemoizedFn((index, targetM) => {
360
+ var _skill$lenses;
361
+ const skill = skillRef.current;
362
+ const lens = skill === null || skill === void 0 || (_skill$lenses = skill.lenses) === null || _skill$lenses === void 0 ? void 0 : _skill$lenses[index];
363
+ if (!skill || !lens) return;
364
+ // 目标值夹进新镜头区间:跨界落点可能略微越界(容差、浮点毛刺)
365
+ const [lo, hi] = lensRange(skill, index);
366
+ const finalM = targetM == null ? lens.basicRatio : Math.min(hi, Math.max(lo, targetM));
367
+ const d = toScaleMultiple(skill, index, finalM);
368
+ const isBase = d <= D_MIN + D_MATCH_EPS;
369
+ console.log('[scaleMultiple] zoomTurntable 切镜头收尾', `lensIndex=${index}`, `finalM=${finalM.toFixed(2)}`, `d=${d.toFixed(3)}`, isBase ? '(归正倍数)' : '(补差值)', `lastD=${getLastD(devId)}`);
370
+
371
+ // 状态先落地:读数 / tab 立刻对齐,不等画面下发走完 P2P
372
+ syncToLens(index, d);
257
373
  markSwitching(-1);
258
- // 切镜头即回到该镜头的光学正倍数,小窗关掉(onChannelChange / 超时兜底都会走到这里)
259
- applySmallMonitor(false);
374
+ settledAtStore.set(devId, Date.now());
375
+ floorHitStore.delete(devId);
376
+ // 小窗跟最终 d 走:补差值后 d > 1(画面不在正倍数上),小窗该开着 ——
377
+ // 旧实现无条件 applySmallMonitor(false),补差值后就与画面状态不符了
378
+ syncSmallMonitorByD(d);
260
379
  event.emit(zoomTurntableLensChange, {
261
380
  lensId: lens.id,
262
381
  lensIndex: index,
263
- value: lens.basicRatio
382
+ value: finalM
264
383
  });
265
384
  event.emit(zoomTurntableZoomChange, {
266
- curM: lens.basicRatio,
267
- d: D_MIN,
385
+ curM: finalM,
386
+ d,
268
387
  lensIndex: index
269
388
  });
389
+ if (isBase) {
390
+ // 归正倍数:与旧行为一致,立即下发
391
+ event.emit(setScaleMultipleEvent, D_MIN);
392
+ return;
393
+ }
394
+ // 补差值:新流刚起来,立刻打 scaleMultiple 有被吞的风险,延一拍;
395
+ // 这段窗口内的复位帧回读由 settleApplyStore 挡掉
396
+ settleApplyStore.set(devId, {
397
+ d,
398
+ expireAt: Date.now() + SETTLE_APPLY_TIMEOUT
399
+ });
400
+ setTimeout(() => {
401
+ console.log('[scaleMultiple] zoomTurntable 补差值下发', d, `lensIndex=${index}`);
402
+ event.emit(setScaleMultipleEvent, d);
403
+ }, POST_SWITCH_APPLY_DELAY);
270
404
  });
271
405
 
272
406
  /** 把目标倍率落到指定镜头上:同镜头内下发本地缩放,跨镜头则切物理镜头并归 1 倍 */
273
407
  const applyToLens = useMemoizedFn(async (nextIndex, targetM) => {
274
- var _skill$lenses;
408
+ var _skill$lenses2;
275
409
  const skill = skillRef.current;
276
- if (!(skill !== null && skill !== void 0 && (_skill$lenses = skill.lenses) !== null && _skill$lenses !== void 0 && _skill$lenses[nextIndex])) return;
410
+ if (!(skill !== null && skill !== void 0 && (_skill$lenses2 = skill.lenses) !== null && _skill$lenses2 !== void 0 && _skill$lenses2[nextIndex])) return;
277
411
  setCurM(targetM);
278
412
  const prevIndex = lensIndexRef.current;
279
413
  if (nextIndex === prevIndex) {
@@ -290,6 +424,9 @@ export const useLensZoom = options => {
290
424
  return;
291
425
  }
292
426
 
427
+ // Entry / Dock 同一帧判出同一次切换时只放一次过(读数已在上面各自更新过)
428
+ if (!claimSwitch(devId, nextIndex)) return;
429
+
293
430
  // 切物理镜头:从下发到 channelChange 回来这段屏蔽回读
294
431
  console.log('[zoomTurntable] 切换镜头', `${prevIndex} -> ${nextIndex}`, `channel=${skill.lenses[nextIndex].id}`, `targetM=${targetM.toFixed(2)}`);
295
432
  takePendingSwitch(devId); // 上一次没结算完的直接作废,以最新这次为准
@@ -305,6 +442,7 @@ export const useLensZoom = options => {
305
442
  pendingSwitchStore.set(devId, {
306
443
  seq,
307
444
  index: nextIndex,
445
+ targetM,
308
446
  timer: setTimeout(() => {
309
447
  pendingSwitchStore.delete(devId);
310
448
  markSwitching(-1);
@@ -328,7 +466,7 @@ export const useLensZoom = options => {
328
466
  // 那时设备已经真的切过去了,画面不会先跳一下再稳定
329
467
  if (!isChannelChangeSupported()) {
330
468
  takePendingSwitch(devId);
331
- settleLensSwitched(nextIndex);
469
+ settleLensSwitched(nextIndex, targetM);
332
470
  return;
333
471
  }
334
472
 
@@ -345,10 +483,49 @@ export const useLensZoom = options => {
345
483
  // 有到达但没有「镜头切换到位」→ 被 deviceId 或 channel 校验挡掉了
346
484
  console.warn('[zoomTurntable] 等 channelChange 超时,按下发结果收尾', `${prevIndex} -> ${nextIndex}`, `channel=${skill.lenses[nextIndex].id}`, `${CHANNEL_SETTLE_TIMEOUT}ms`, `onChannelChange 可用=${isChannelChangeSupported()}`);
347
485
  dumpChannelApis();
348
- settleLensSwitched(nextIndex);
486
+ settleLensSwitched(nextIndex, targetM);
349
487
  }, CHANNEL_SETTLE_TIMEOUT);
350
488
  });
351
489
 
490
+ /**
491
+ * 拖动跨镜头临界:过界后**手停住** DRAG_CROSS_SWITCH_DELAY 就地切镜头,画面跟着拖动走。
492
+ *
493
+ * previewZoom 把下发值夹在当前镜头区间内,所以拖过界之后画面是「冻」住的 ——
494
+ * 只等松手才切的话,从 3.0 往下拖到 2.5 的一路上画面纹丝不动,手感像是没响应。
495
+ * 逐帧切也不行:switchChannel 是 P2P、几百毫秒级,逐帧下发会把通道打爆。
496
+ * 折中成 trailing 防抖:值一直在变就一直不切,停住才切。
497
+ *
498
+ * 切换期间 draggingRef 保持 true(见 onChannelChange 里的说明),松手后 applyZoom
499
+ * 再按最终值落一次 —— 那时镜头通常已经对了,走的是同镜头内下发 d 的分支。
500
+ */
501
+ const scheduleDragCross = useMemoizedFn(value => {
502
+ const clearPending = () => {
503
+ if (dragCrossTimerRef.current) {
504
+ clearTimeout(dragCrossTimerRef.current);
505
+ dragCrossTimerRef.current = undefined;
506
+ }
507
+ };
508
+ const skill = skillRef.current;
509
+ if (!skill) return;
510
+ // 回到当前镜头区间内 / 已在切换中:撤掉待切
511
+ if (matchLens(skill, value) === lensIndexRef.current || isSwitching(devId)) {
512
+ clearPending();
513
+ return;
514
+ }
515
+ clearPending();
516
+ dragCrossTimerRef.current = setTimeout(() => {
517
+ dragCrossTimerRef.current = undefined;
518
+ // 已松手 → 交给 applyZoom 落地,不在这儿抢
519
+ if (!draggingRef.current || isSwitching(devId)) return;
520
+ const cur = skillRef.current;
521
+ if (!cur) return;
522
+ const nextIndex = matchLens(cur, value);
523
+ if (nextIndex === lensIndexRef.current) return;
524
+ console.log('[zoomTurntable] 拖动跨界停留,就地切镜头', `${lensIndexRef.current} -> ${nextIndex}`, `targetM=${value.toFixed(2)}`);
525
+ applyToLens(nextIndex, value);
526
+ }, DRAG_CROSS_SWITCH_DELAY);
527
+ });
528
+
352
529
  /**
353
530
  * 拖动状态兜底复位。
354
531
  *
@@ -359,11 +536,22 @@ export const useLensZoom = options => {
359
536
  */
360
537
  const endDrag = useMemoizedFn(() => {
361
538
  draggingRef.current = false;
539
+ draggingStore.delete(devId);
540
+ if (dragCrossTimerRef.current) {
541
+ clearTimeout(dragCrossTimerRef.current);
542
+ dragCrossTimerRef.current = undefined;
543
+ }
362
544
  });
363
545
 
364
546
  /** 拖动结束:按倍率匹配镜头后落地 */
365
547
  const applyZoom = useMemoizedFn(async targetM => {
366
548
  draggingRef.current = false;
549
+ draggingStore.delete(devId);
550
+ // 松手即是最终值,待切的那次作废,直接按最终值走
551
+ if (dragCrossTimerRef.current) {
552
+ clearTimeout(dragCrossTimerRef.current);
553
+ dragCrossTimerRef.current = undefined;
554
+ }
367
555
  const skill = skillRef.current;
368
556
  if (!skill) return;
369
557
  await applyToLens(matchLens(skill, targetM), targetM);
@@ -371,11 +559,12 @@ export const useLensZoom = options => {
371
559
 
372
560
  /** 点击倍率 tab:直接切到该镜头并归位到其光学倍数;不走 matchLens,免得容差判到相邻镜头 */
373
561
  const switchToLens = useMemoizedFn(async index => {
374
- var _skill$lenses2;
562
+ var _skill$lenses3;
375
563
  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];
564
+ const lens = skill === null || skill === void 0 || (_skill$lenses3 = skill.lenses) === null || _skill$lenses3 === void 0 ? void 0 : _skill$lenses3[index];
377
565
  if (!lens) return;
378
566
  draggingRef.current = false;
567
+ draggingStore.delete(devId);
379
568
  await applyToLens(index, lens.basicRatio);
380
569
  });
381
570
 
@@ -406,9 +595,11 @@ export const useLensZoom = options => {
406
595
  return; // 外部来源的重复事件
407
596
  }
408
597
  console.log('[zoomTurntable] 镜头切换到位', `${lensIndexRef.current} -> ${index}`, pending ? '(本端下发)' : '(外部来源)');
409
- // 读数以设备为准,进行中的拖动作废
410
- draggingRef.current = false;
411
- settleLensSwitched(index);
598
+ // 只有外部来源(设备侧 / 其他端)才作废进行中的拖动 —— 读数得以设备为准。
599
+ // 本端下发的那次不能动 draggingRef:拖动跨界会在**手指还按着**的时候切镜头,
600
+ // 置 false 会放开回读屏蔽,回读帧和拖动帧互相打架,指针会抖。
601
+ if (!pending) draggingRef.current = false;
602
+ settleLensSwitched(index, pending === null || pending === void 0 ? void 0 : pending.targetM);
412
603
  });
413
604
  useEffect(() => {
414
605
  // 订没订上必须能从日志看出来:没订上时切镜头一定走超时回滚,两者表象一样
@@ -434,15 +625,94 @@ export const useLensZoom = options => {
434
625
  gestureTimerRef.current = setTimeout(() => {
435
626
  gestureTimerRef.current = undefined;
436
627
  const skill = skillRef.current;
437
- if (!skill || draggingRef.current || isSwitching(devId)) return;
628
+ if (!skill || draggingRef.current || isDraggingAnywhere(devId) || isSwitching(devId)) return;
438
629
  const nextIndex = matchLens(skill, value);
439
630
  if (nextIndex === lensIndexRef.current) return;
440
631
  console.log('[zoomTurntable] 手势跨临界点,切换镜头', `curM=${value.toFixed(2)}`, `${lensIndexRef.current} -> ${nextIndex}`);
441
632
  applyToLens(nextIndex, value);
442
633
  }, GESTURE_SWITCH_DEBOUNCE);
443
634
  });
635
+
636
+ /**
637
+ * 触底继续缩 → 往下切一个镜头(trailing 防抖,等手指停下再切)。
638
+ *
639
+ * 现象:在非首个镜头上把画面缩到该镜头的光学正倍数后(d = 1,协议下限),用户继续
640
+ * 双指往里收,播放器的 d 已经夹住动不了,画面纹丝不动,读数也停在 basicRatio。
641
+ * 此时 zoomLevelChange 仍然按帧推 d = 1 上来 —— 这就是唯一可用的信号
642
+ * (detail.scaleMode「自由缩放状态标识」实测不准,不能拿来判定)。
643
+ *
644
+ * 落位取「上一个镜头区间上限 − 1 step」(如 3.0 → 2.9):那正是画面能继续变小的第一格,
645
+ * 换算到短焦上是 d ≈ 3.85,用户接着往里收就能顺畅地一路缩到 1.0。
646
+ */
647
+ const scheduleDownSwitch = useMemoizedFn(() => {
648
+ var _downSwitchAtStore$ge;
649
+ if (Date.now() - ((_downSwitchAtStore$ge = downSwitchAtStore.get(devId)) !== null && _downSwitchAtStore$ge !== void 0 ? _downSwitchAtStore$ge : 0) < DOWN_SWITCH_COOLDOWN) return;
650
+ if (downTimerRef.current) clearTimeout(downTimerRef.current);
651
+ downTimerRef.current = setTimeout(() => {
652
+ var _skill$lenses$target$, _skill$lenses4;
653
+ downTimerRef.current = undefined;
654
+ const skill = skillRef.current;
655
+ const index = lensIndexRef.current;
656
+ if (!skill || index <= 0) return;
657
+ if (draggingRef.current || isDraggingAnywhere(devId) || isSwitching(devId)) return;
658
+ // 防抖这 500ms 里可能又被放大了,切之前再确认画面确实还夹在正倍数上
659
+ if (getLastD(devId) > D_MIN + D_MATCH_EPS) {
660
+ floorHitStore.delete(devId);
661
+ return;
662
+ }
663
+ const target = index - 1;
664
+ const [, upper] = lensRange(skill, target);
665
+ 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;
666
+ const targetM = upper - stepOfTarget;
667
+ downSwitchAtStore.set(devId, Date.now());
668
+ floorHitStore.delete(devId);
669
+ console.log('[zoomTurntable] 触底继续缩,向下切镜头', `${index} -> ${target}`, `targetM=${targetM.toFixed(2)}`);
670
+ applyToLens(target, targetM);
671
+ }, GESTURE_SWITCH_DEBOUNCE);
672
+ });
673
+
674
+ /**
675
+ * 触底计数。返回 true 表示已排下切,本帧不再走上界 / 防抖那套判定。
676
+ * 只由驱动方计数 —— 两份 hook 都数会让阈值形同减半。
677
+ */
678
+ const handleFloorHit = useMemoizedFn(d => {
679
+ var _settledAtStore$get;
680
+ if (!isAutoSwitchDriver) return false;
681
+ // 拖动中:落地由 applyZoom / 跨界停留负责,触底那套整体让路
682
+ if (isDraggingAnywhere(devId)) {
683
+ floorHitStore.delete(devId);
684
+ return false;
685
+ }
686
+ // 不在正倍数上:正常缩放,清计数
687
+ if (d > D_MIN + D_MATCH_EPS) {
688
+ floorHitStore.delete(devId);
689
+ return false;
690
+ }
691
+ // 首个镜头已经是倍率域下界,再缩没有镜头可切
692
+ if (lensIndexRef.current <= 0) return false;
693
+ const now = Date.now();
694
+ // 收尾自身会引发一到两帧 d = 1 回读,静默期内不计数,否则刚切上去又被切回来
695
+ if (now - ((_settledAtStore$get = settledAtStore.get(devId)) !== null && _settledAtStore$get !== void 0 ? _settledAtStore$get : 0) < FLOOR_HIT_SETTLE_GUARD) return false;
696
+ const prev = floorHitStore.get(devId);
697
+ const count = prev && now - prev.lastAt <= FLOOR_HIT_WINDOW ? prev.count + 1 : 1;
698
+ floorHitStore.set(devId, {
699
+ count,
700
+ lastAt: now
701
+ });
702
+ // 每一帧都打:真机上「触底还缩不缩得动」全靠这条日志判断 ——
703
+ // 如果原生只在 d 真正变化时才推事件,这里会永远停在 count=1、下切一次都不会触发,
704
+ // 那就说明这条信号不成立,得改由 APP 侧补「手势下界」参数(协议 §4.3 第 5 条)
705
+ console.log('[zoomTurntable] 触底回读', `count=${count}/${FLOOR_HIT_COUNT}`, `lensIndex=${lensIndexRef.current}`);
706
+ // 第一帧是「正常缩到正倍数」,从第二帧起才是「夹住了还在缩」
707
+ if (count < FLOOR_HIT_COUNT) return false;
708
+ console.log('[zoomTurntable] 画面已夹在正倍数仍有缩放回读,排下切', `count=${count}`);
709
+ scheduleDownSwitch();
710
+ return true;
711
+ });
444
712
  const onZoomLevelChange = useMemoizedFn(d => {
445
713
  if (!(d > 0)) return;
714
+ // 补差值下发期间原生画面会先复位到 1 倍,那一帧不能让它把读数拽回镜头正倍数
715
+ if (shouldDropSettleEcho(devId, d)) return;
446
716
  setLastD(devId, d);
447
717
  // 切镜头途中的中间态不算数(收尾时统一关掉),拖动中画面还没动
448
718
  if (draggingRef.current || isSwitching(devId)) return;
@@ -458,6 +728,13 @@ export const useLensZoom = options => {
458
728
  lensIndex: lensIndexRef.current
459
729
  });
460
730
 
731
+ // 触底继续缩:往下切一个镜头(下界,与下面的上界对称)
732
+ if (handleFloorHit(d)) return;
733
+ if (!isAutoSwitchDriver) return;
734
+ // 拖动中:previewZoom 把下发值夹在当前镜头区间内,回读会一直贴着区间边界,
735
+ // 不让路的话「触到上界立即切」会和拖动跨界停留同时切镜头
736
+ if (isDraggingAnywhere(devId)) return;
737
+
461
738
  // 画面已经顶到当前镜头区间的上界(也就是下一个镜头的光学倍数,如 1x 镜头放到 3.0):
462
739
  // 这个边界值在归属上属于下一个镜头,必须立刻切过去。
463
740
  // 走 trailing 防抖的话,那 500ms 里 tab 会挂着「下一镜头的值」显示在当前镜头上。
@@ -484,6 +761,8 @@ export const useLensZoom = options => {
484
761
  useEffect(() => {
485
762
  return () => {
486
763
  if (gestureTimerRef.current) clearTimeout(gestureTimerRef.current);
764
+ if (downTimerRef.current) clearTimeout(downTimerRef.current);
765
+ if (dragCrossTimerRef.current) clearTimeout(dragCrossTimerRef.current);
487
766
  };
488
767
  }, []);
489
768
  const [min, max] = zoomSkill ? zoomBounds(zoomSkill) : [D_MIN, D_MIN];
@@ -3,7 +3,7 @@ import { ComponentConfigProps } from '../../interface';
3
3
  import './zoomTurntableEntry.less';
4
4
  type Props = ComponentConfigProps & {
5
5
  className?: string;
6
- /** 业务侧的功能开关;实际是否渲染仍由 ipc_multi_zoom 解析出的 cnt 决定 */
6
+ /** 业务侧的功能开关;实际是否渲染仍由宿主 switchChannel 能力 + ipc_multi_zoom cnt 决定 */
7
7
  showZoomTurntable?: boolean;
8
8
  /** 长按判定时长,默认 500ms */
9
9
  longPressDuration?: number;
@@ -12,7 +12,8 @@ type Props = ComponentConfigProps & {
12
12
  };
13
13
  /**
14
14
  * 焦距转盘入口:左下角工具条中的图标,长按弹出转盘。
15
- * cnt > 1 的多镜头设备渲染;单镜头设备倍率交互仍由 RealTimeMagnification 承担。
15
+ * 仅「宿主支持 ty.ipc.switchChannel」且 cnt > 1 的多镜头设备渲染;
16
+ * 单镜头设备倍率交互仍由 RealTimeMagnification 承担。
16
17
  */
17
18
  export declare const ZoomTurntableEntry: (props: Props) => React.JSX.Element | null;
18
19
  export {};
@@ -19,7 +19,8 @@ import { FORCE_SHOW_ZOOM_TIP, LONG_PRESS_MS, MOVE_TOLERANCE, TIP_RESHOW_DELAY, g
19
19
  import './zoomTurntableEntry.less';
20
20
  /**
21
21
  * 焦距转盘入口:左下角工具条中的图标,长按弹出转盘。
22
- * cnt > 1 的多镜头设备渲染;单镜头设备倍率交互仍由 RealTimeMagnification 承担。
22
+ * 仅「宿主支持 ty.ipc.switchChannel」且 cnt > 1 的多镜头设备渲染;
23
+ * 单镜头设备倍率交互仍由 RealTimeMagnification 承担。
23
24
  */
24
25
  export const ZoomTurntableEntry = props => {
25
26
  var _props$event;
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.5",
4
4
  "description": "IPC 融合播放器",
5
5
  "main": "lib/index",
6
6
  "files": [