@ray-js/ipc-player-integration 0.0.50-beta.2 → 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.
@@ -1,3 +1,4 @@
1
+ import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
1
2
  /** absolute 槽位 id:转盘浮层 */
2
3
  export const zoomTurntableDockId = 'zoomTurntableDockId';
3
4
 
@@ -36,15 +37,20 @@ export const LONG_PRESS_MS = 500;
36
37
  /** 长按期间允许的位移容差(px),超出视为横滑并取消长按 */
37
38
  export const MOVE_TOLERANCE = 10;
38
39
 
39
- /** switchChannel 下发超时(ms):回调始终不来时不知道切没切成,超时回滚读数 */
40
- export const SWITCH_LENS_TIMEOUT = 3000;
40
+ /**
41
+ * switchChannel 下发超时(ms):回调始终不来时不知道切没切成,超时回滚读数。
42
+ *
43
+ * 取 10s:设备切镜头本身就慢,回调回来得晚是常态;给短了会在回调还在路上时
44
+ * 先把读数回滚掉,随后回调又落地,反而多一次跳变。
45
+ */
46
+ export const SWITCH_LENS_TIMEOUT = 10000;
41
47
 
42
48
  /**
43
49
  * switchChannel 已 success 后,等 channelChange 报「切到位」的时长(ms)。
44
50
  * 超时按下发结果自己收尾,**不回滚** —— 指令已被受理,物理镜头其实已经切了,
45
51
  * 回滚读数只会让读数和画面对不上。
46
52
  *
47
- * 取 10s,比 SWITCH_LENS_TIMEOUT 宽得多:设备真正切完镜头本身要一会儿,
53
+ * 取 10s,与 SWITCH_LENS_TIMEOUT 同档:设备真正切完镜头本身要一会儿,
48
54
  * 给短了会在事件晚到时提前自己收尾,等于白订阅。
49
55
  * 真机现状:设备侧暂未推 channelChange,切镜头基本都靠这条超时收尾。
50
56
  */
@@ -66,71 +72,263 @@ export const QUERY_LENS_TIMEOUT = 3000;
66
72
  */
67
73
  export const GESTURE_SWITCH_DEBOUNCE = 500;
68
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
+
69
125
  /** dock 层级,需高于四角容器的 z-index: 100 */
70
126
  export const ZOOM_DOCK_Z_INDEX = 120;
71
127
 
72
128
  /**
73
- * 竖屏截取比例:从椭圆上弧顶往下截取多少。
74
- * 0.5 = 完整上半椭圆(截面底边落在最宽处);> 0.5 会带出两侧下弯段。
75
- * 取 0.5 让背景一路铺到播放器底边、不留空隙,多出来的部分靠 ZOOM_DOCK_BOTTOM 探出被裁掉。
129
+ * 竖屏转盘占播放器高度的比例:1/3。
130
+ * 播放器容器按 16:9,所以高度由容器宽度推导,见 getVerticalPlayerHeight。
131
+ */
132
+ const ZOOM_HEIGHT_RATIO = 1 / 3;
133
+
134
+ /**
135
+ * 椭圆纵半径与背景带高度的比值,决定弧的**弯度**。
136
+ *
137
+ * 弧顶到屏幕两侧的落差 = ry × (1 − √(1 − (屏幕半宽 / 横半径)²));横半径也按容器宽缩放
138
+ * (见 ELLIPSE_RX_TO_WIDTH),所以括号里恒为 0.727,落差恒为 ry × 0.313。
139
+ *
140
+ * 取 1.6:74px 高的带子(393 宽机型)对应 37px 落差,与设计稿量出来的弧度一致。
141
+ * 曾用 1.83(落差 43px),弧偏弯。比值调大 = 更弯,调小 = 更平;与带高解耦,
142
+ * 改一个不影响另一个。
143
+ */
144
+ const ELLIPSE_RY_TO_HEIGHT = 1.6;
145
+
146
+ /**
147
+ * 椭圆横半径与容器宽度的比值(= 设计稿 258 / 375)。
148
+ *
149
+ * 椭圆比播放器宽约 37%,两端伸到播放器之外,背景才能左右铺满、两侧不露直角缺口。
150
+ * 必须跟着容器宽度缩放:弧顶到屏幕两侧的落差取决于「屏幕半宽 / 横半径」这个比值,
151
+ * 横半径写死 258 的话,宽屏机型上比值变大、弧明显更弯(430 宽时两侧只剩 15px)。
152
+ */
153
+ const ELLIPSE_RX_TO_WIDTH = 258 / 375;
154
+
155
+ /** 兜底容器宽度:getSystemInfoSync 在 onLoad 之前可能取不到值 */
156
+ const FALLBACK_WINDOW_WIDTH = 375;
157
+
158
+ /**
159
+ * 竖屏播放器容器宽度 = 窗口宽(竖屏播放器满宽铺开)。
160
+ *
161
+ * 用全局 `ty` 而不是 import `@ray-js/ray`:本模块被 lensSkill / lensModel 一路引到单测里,
162
+ * 一旦引入运行时依赖,jest 解析不了这个包,整条测试链会跟着挂。
163
+ */
164
+ const getContainerWidth = () => {
165
+ try {
166
+ var _ty, _ty$getSystemInfoSync;
167
+ return ((_ty = ty) === null || _ty === void 0 || (_ty$getSystemInfoSync = _ty.getSystemInfoSync) === null || _ty$getSystemInfoSync === void 0 || (_ty$getSystemInfoSync = _ty$getSystemInfoSync.call(_ty)) === null || _ty$getSystemInfoSync === void 0 ? void 0 : _ty$getSystemInfoSync.windowWidth) || FALLBACK_WINDOW_WIDTH;
168
+ } catch (err) {
169
+ console.warn('[zoomTurntable] getSystemInfoSync 失败,容器宽按 375 兜底', err);
170
+ return FALLBACK_WINDOW_WIDTH;
171
+ }
172
+ };
173
+
174
+ /**
175
+ * 竖屏播放器高度:容器按 16:9 推导。
176
+ * 不用固定值,pad / 折叠屏 / 大屏机型上转盘才能始终占到 1/3。
177
+ */
178
+ export const getVerticalPlayerHeight = () => getContainerWidth() * 9 / 16;
179
+
180
+ /** 竖屏遮罩椭圆横半径:跟着容器宽度缩放,各机型弧形一致 */
181
+ export const getEllipseRxBottom = () => Math.round(getContainerWidth() * ELLIPSE_RX_TO_WIDTH);
182
+
183
+ /** 竖屏背景弧顶到播放器底边的可见高度:播放器高的 1/3(375 宽机型约 70px) */
184
+ export const getZoomVisibleHeightBottom = () => Math.round(getVerticalPlayerHeight() * ZOOM_HEIGHT_RATIO);
185
+
186
+ /** 竖屏遮罩椭圆纵半径:跟着可见高度按 ELLIPSE_RY_TO_HEIGHT 缩放,各机型弯度一致 */
187
+ export const getEllipseRyBottom = () => Math.round(getZoomVisibleHeightBottom() * ELLIPSE_RY_TO_HEIGHT);
188
+
189
+ /**
190
+ * 竖屏截取比例:从椭圆上弧顶往下截多深。
191
+ *
192
+ * = 可见高度 / 椭圆全高 = 1 / (2 × ELLIPSE_RY_TO_HEIGHT) ≈ 0.273,与机型无关
193
+ * (可见高与纵半径同比例缩放)。**关键**:截面高度就等于要显示的高度,
194
+ * 不再靠「椭圆探出播放器底边、多出部分被裁」——
195
+ * CoverView 里 canvas 的负 margin 并不可靠,一旦不生效,探出去的那截就会原地显示出来,
196
+ * 表现就是转盘整体被顶高一截(实测占到播放器高的 1/2)、底边还留一条透明缝。
197
+ * 现在 canvas 底边 = 内容底边 = 播放器底边,不依赖负 margin 与裁切。
198
+ */
199
+ export const CROP_RATIO_BOTTOM = 1 / (2 * ELLIPSE_RY_TO_HEIGHT);
200
+
201
+ /**
202
+ * 轨道虚线弧与外圈遮罩弧之间的等距间距(px,scale=1)。
203
+ *
204
+ * rjs 侧按「横纵半径各减同一个绝对值」内收,所以这个值就是两条弧线处处的间距,
205
+ * 两条弧因此弧度一致、看着是一对平行弧(旧的按比例内收会让弧顶间距远小于两侧)。
206
+ *
207
+ * 用绝对值而不是跟着带高缩放:竖屏背景带本身就只有 70px 上下,间距按比例缩会失真。
208
+ *
209
+ * 竖屏取 32:它同时决定「文案下缘到虚线弧顶」的距离 —— 文案占掉
210
+ * LABEL_TOP_GAP_BOTTOM + 字高(11 + 12 = 23px),剩下 9px 就是两者之间的留白,
211
+ * 与设计稿一致(28 时只剩 5px,文字贴着刻度线)。
212
+ * 横屏取 40,与改动前的弧顶间距(39.25)保持一致,横屏观感不变。
213
+ */
214
+ export const TRACK_EDGE_INSET_BOTTOM = 32;
215
+ export const TRACK_EDGE_INSET_RIGHT = 40;
216
+
217
+ /**
218
+ * 竖屏刻度文案「顶边到背景弧」的留白(px,scale=1)。
219
+ *
220
+ * 文案位置以**遮罩弧**为基准、与它等距内收,而不是以轨道弧往外推:
221
+ * 这样调轨道间距(TRACK_EDGE_INSET_BOTTOM)文案不会跟着上下窜,
222
+ * 当前倍率(12px)与刻度数字(10px)两种字号的顶边也落在同一条线上。
223
+ *
224
+ * 取 11 = 最初的 6 再加 5:文字与外边缘贴太近显得局促。
76
225
  */
77
- export const CROP_RATIO_BOTTOM = 0.5;
226
+ export const LABEL_TOP_GAP_BOTTOM = 11;
78
227
 
79
- /** 基准椭圆全高,与 rjs 里的 BASE_RY * 2 保持一致 */
80
- const BASE_ELLIPSE_HEIGHT = 351;
228
+ /**
229
+ * 竖屏虚线弧「扎出播放器底边的位置」距左右边的距离(px)。
230
+ *
231
+ * 设计稿里刻度线两端是**插进播放器底部**的,而且插出去的位置离左右边还有一段距离 ——
232
+ * 不是贴着左右下角擦出去(那样看着就是「顶到最左最右」)。
233
+ */
234
+ export const TRACK_SIDE_GAP_BOTTOM = 28;
235
+
236
+ /**
237
+ * 竖屏轨道椭圆的横半径:由「虚线弧在距左右边 TRACK_SIDE_GAP_BOTTOM 处正好降到播放器底边」反推。
238
+ *
239
+ * 设 x = 屏幕半宽 − 留白、弧顶到底边的高度 h = 可见高 − 间距、纵半径 ry:
240
+ * ry × (1 − √(1 − (x / rx)²)) = h ⟹ rx = x / √(1 − (1 − h/ry)²)
241
+ *
242
+ * 算出来的 rx 比「遮罩横半径 − 间距」小一截,所以虚线弧比遮罩弧更弯 ——
243
+ * 两条弧只在弧顶附近保持 TRACK_EDGE_INSET_BOTTOM 的间距,往两侧虚线弧下坠得更快,
244
+ * 这正是设计稿的样子(背景横向铺满、刻度线扎进底部)。
245
+ */
246
+ export const getTrackRxBottom = () => {
247
+ const x = getContainerWidth() / 2 - TRACK_SIDE_GAP_BOTTOM;
248
+ const trackRy = getEllipseRyBottom() - TRACK_EDGE_INSET_BOTTOM;
249
+ const arcTopToBottom = getZoomVisibleHeightBottom() - TRACK_EDGE_INSET_BOTTOM;
250
+ if (trackRy <= 0 || arcTopToBottom <= 0) return 0;
251
+ const k = 1 - arcTopToBottom / trackRy;
252
+ const denom = Math.sqrt(Math.max(0.0001, 1 - k * k));
253
+ return Math.round(x / denom);
254
+ };
81
255
 
82
256
  /**
83
257
  * 触摸响应余量:canvas 是唯一能收 touch 的元素,把它向外扩一圈,
84
258
  * **整个转盘范围(椭圆截面 + 上方文案区 + 两侧空白)都能拖动**,而不是只有刻度线那一带。
85
259
  *
86
- * 绘制坐标由 renderer 按同样的余量补偿,视觉不变;下面的 ZOOM_DOCK_BOTTOM /
87
- * ZOOM_DOCK_RIGHT 也都从「内容边距」反推,改这两个值不会让转盘位置偏移。
260
+ * 绘制坐标由 renderer 按同样的余量补偿,视觉不变;下面的 dock 位置也都从「内容边距」反推,
261
+ * 改这两个值不会让转盘位置偏移。
88
262
  */
89
263
  export const HIT_PADDING_X = 48;
90
264
  export const HIT_PADDING_Y = 56;
91
265
 
92
266
  /**
93
- * 竖屏 dock 高度:必须 >= canvas 实际高度(椭圆截面 + 上下触摸余量),
94
- * 否则承载它的 CoverView 会把转盘裁掉一截。随 CROP_RATIO_BOTTOM / HIT_PADDING_Y 自动跟随,留 8px 余量。
267
+ * **`absolutePosition` 里的长度一律写成带单位的 px 字符串**(`'48px'`),不要写裸数字。
268
+ *
269
+ * 裸数字会被宿主按 **rpx**(750 设计稿基准)解释。实测(393 宽机型):给 `height: 194`
270
+ * 量到 101px、给 `bottom: -56` 量到 -29px,比例正好 393 / 750。历史上那条
271
+ * 「dock 可命中高度只有 155、canvas 高 287.5,顶部溢出被裁」就是这么来的 ——
272
+ * 当时 dock 高写 296,实际只有 296 × 393/750 ≈ 155px,dock 只有 canvas 一半高,
273
+ * canvas 顶部永远露在 CoverView 外、收不到 touch。
274
+ *
275
+ * canvas 自己的尺寸是 rjs 里用 `'...px'` 设的、本来就是真 px;dock 也统一用 px 字符串之后,
276
+ * 两边同一套单位,不再需要任何换算,也不受 rpx 取整误差影响
277
+ * (横屏尤其明显:它的 rpx 基准是屏幕长边,机型间差 150px 以上)。
95
278
  */
96
- export const ZOOM_DOCK_HEIGHT = Math.ceil(BASE_ELLIPSE_HEIGHT * CROP_RATIO_BOTTOM) + HIT_PADDING_Y * 2 + 8;
279
+ const px = value => `${Math.round(value)}px`;
97
280
 
98
281
  /**
99
- * 绘制内容(椭圆截面)顶边到播放器底边的距离 —— 真正的视觉基准。
100
- *
101
- * canvas dock 内是底部对齐的,内容在 canvas 里又偏移了一份 HIT_PADDING_Y,
102
- * 所以位置要从内容边界反推、而不是从 dock 边界反推,否则一改触摸余量转盘就整体挪位。
103
- * 内容顶距播放器底 = 截面高 + HIT_PADDING_Y + ZOOM_DOCK_BOTTOM
282
+ * 竖屏底部工具条高度的兜底值(px):实测不到时用它。
283
+ * 对应 ui.tsx 里 `.ipc-player-bottom-content` 竖屏分支的 `height: '48px'`;
284
+ * `-full` 变体还叠了 safe-area padding,所以正常路径走实测(measureBottomBarHeight)。
104
285
  */
105
- const ZOOM_CONTENT_TOP_GAP = 137;
286
+ export const BOTTOM_BAR_HEIGHT_VERTICAL = 48;
106
287
 
107
288
  /**
108
- * 竖屏 dock 距播放器底边的距离,固定 0(贴底,不再探出)。
109
- *
110
- * 这里曾用负值让浮层整体下沉、把椭圆下半探出播放器底边。副作用是 CoverView 顶边
111
- * 跟着被压低,canvas 顶部(含刻度线那一带)露在 CoverView 之外被裁掉 —— 原生层裁掉的
112
- * 部分收不到 touch,表现就是「只有刻度尺下方能滑,上半部分直接拖到播放器」。
113
- * 实测:dock 可命中高度只有 155,而 canvas 287.5,顶部溢出 132.5。
289
+ * 竖屏 canvas 高度(px)。两种布局的底部触摸余量不同:
290
+ * - 竖屏全屏:底部余量 0 —— canvas 底边就是工具条顶边,再留余量会盖住按钮并吃掉点击
291
+ * (canvas CoverView 里,层级高于普通 View);
292
+ * - 普通竖屏:工具条已被藏掉,余量上下对称,底部那一份靠 dock 的负 bottom 沉到播放器外。
293
+ */
294
+ const getZoomCanvasHeightBy = isVerticalFull => getZoomVisibleHeightBottom() + HIT_PADDING_Y * (isVerticalFull ? 1 : 2);
295
+
296
+ /**
297
+ * 竖屏 dock 距播放器底边的距离(px 字符串)。
114
298
  *
115
- * 现在改由 canvas 自己用负 margin 向下探出(见 ZOOM_CANVAS_OVERSHOOT_BOTTOM),
116
- * 浮层顶边因此上移,canvas 完整落在可命中区内,而内容视觉位置不变。
299
+ * - 竖屏全屏:**正值**,抬起一整条底部工具条的高度(优先用实测值,避免与 ui.tsx 写死的
300
+ * 48px 以及 `-full` 变体的 safe-area padding 不同步),于是 canvas 底边 = 工具条顶边;
301
+ * - 普通竖屏:**负值**,下沉一份底部触摸余量,让绘制内容底边正好落在播放器底边上。
302
+ * (负 bottom 是生效的;canvas 的负 marginBottom 才是无效的那个,别再往回改。)
117
303
  */
118
- export const ZOOM_DOCK_BOTTOM = 0;
304
+ export const getZoomDockBottom = (isVerticalFull, measuredBarHeight) => isVerticalFull ? px(measuredBarHeight && measuredBarHeight > 0 ? measuredBarHeight : BOTTOM_BAR_HEIGHT_VERTICAL) : px(-HIT_PADDING_Y);
119
305
 
120
306
  /**
121
- * 竖屏 canvas 底边探出播放器底边的距离。
122
- * canvas 在 dock 内底部对齐,靠这个负 margin 下移,使绘制内容仍然铺到播放器底边:
123
- * 内容底探出 = ZOOM_CANVAS_OVERSHOOT_BOTTOM - HIT_PADDING_Y
124
- * 探出去的部分由 .ipc-player-content 的 overflow: hidden 裁掉,与改动前的观感一致。
307
+ * 竖屏 dock 高度(px 字符串):必须 >= canvas 高度,否则承载它的 CoverView 会把转盘裁掉一截,
308
+ * 被裁掉的部分收不到 touch(「只有刻度尺下方能滑」/「手势完全不生效」都是它)。
309
+ * 普通竖屏还要把下沉的那一份底部余量算进来,所以 canvas 高本身就带两份余量。
125
310
  */
126
- export const ZOOM_CANVAS_OVERSHOOT_BOTTOM = Math.ceil(BASE_ELLIPSE_HEIGHT * CROP_RATIO_BOTTOM) + HIT_PADDING_Y - ZOOM_CONTENT_TOP_GAP;
311
+ export const getZoomDockHeight = isVerticalFull => px(getZoomCanvasHeightBy(isVerticalFull) + 8);
312
+
313
+ /** 竖屏 canvas / dock 的 px 尺寸,仅用于布局诊断日志与文档核对 */
314
+ export const getZoomLayoutDebugPx = isVerticalFull => ({
315
+ 可见高: getZoomVisibleHeightBottom(),
316
+ canvas高: Math.round(getZoomCanvasHeightBy(isVerticalFull)),
317
+ dock高: Math.round(getZoomCanvasHeightBy(isVerticalFull) + 8)
318
+ });
127
319
 
128
320
  /**
129
- * 横屏内容右边相对播放器右边的内缩距离(视觉基准)。
321
+ * 横屏内容右边相对播放器右边的内缩距离(视觉基准),取 0 = **贴齐播放器右边缘**。
322
+ *
130
323
  * canvas 在 dock 内右对齐,内容右边又内缩一份 HIT_PADDING_Y(横屏画布旋转 90°):
131
- * 内容右边内缩 = ZOOM_DOCK_RIGHT + HIT_PADDING_Y
324
+ * 内容右边内缩 = dock right(px) + HIT_PADDING_Y = 这个值
325
+ * 写进 absolutePosition 的 px 字符串由 getZoomDockRight 产出。
326
+ *
327
+ * 原来是 14 —— 遮罩右侧因此始终差 14px 铺不到边,画面从那条缝里透出来,
328
+ * 就是「横屏右边有空白」。遮罩的平底边(横屏映射到屏幕右缘)另有 EDGE_OVERFILL
329
+ * 往外多铺一点点,配合这里的 0 才真正做到无缝。
132
330
  */
133
- const ZOOM_CONTENT_RIGHT_INSET = 14;
331
+ const ZOOM_CONTENT_RIGHT_INSET = 0;
134
332
 
135
333
  /**
136
334
  * 拖动转盘时连续下发缩放的节流间隔(ms)。
@@ -139,31 +337,54 @@ const ZOOM_CONTENT_RIGHT_INSET = 14;
139
337
  */
140
338
  export const ZOOM_PREVIEW_THROTTLE = 80;
141
339
 
142
- /** 横屏 dock 距播放器右边的距离,负值时向右探出 */
143
- export const ZOOM_DOCK_RIGHT = ZOOM_CONTENT_RIGHT_INSET - HIT_PADDING_Y;
340
+ /**
341
+ * 横屏 dock 距播放器右边的距离(px 字符串,负值向右探出)。
342
+ *
343
+ * 之前写的是裸数字、被当 rpx 解释,而横屏的 rpx 基准是屏幕**长边**、机型间差 150px 以上,
344
+ * 换算下来内容内缩量在 3.8~12.3px 之间飘(窄屏留明显缝、宽屏几乎贴边,
345
+ * 就是「有些机型右边有 1px 空白」的来源)。写成 px 之后各机型恒为 14px。
346
+ *
347
+ * 内容右边内缩 = 本函数的 px 值 + HIT_PADDING_Y = ZOOM_CONTENT_RIGHT_INSET = 14px
348
+ */
349
+ export const getZoomDockRight = () => px(ZOOM_CONTENT_RIGHT_INSET - HIT_PADDING_Y);
144
350
 
145
351
  /** 横屏截取比例(右侧展示空间较窄,截得更深) */
146
352
  export const CROP_RATIO_RIGHT = 0.45;
147
353
 
148
- /** 布局沉降兜底:onShow / onResize 后的重挂载延时 */
149
- export const RELAYOUT_DELAYS = [80, 260];
150
-
151
- /** 刻度与文案整体下移量(角度不变,仅平移绘制坐标系) */
354
+ /**
355
+ * 横屏刻度与文案的整体下移量(角度不变,仅平移绘制坐标系)。
356
+ * 竖屏不用它(截面已精确等于可见区,再平移会把底边裁掉一截),固定传 0。
357
+ */
152
358
  export const CONTENT_OFFSET_Y = 10;
153
359
 
154
- /** 刻度与标记值向椭圆内侧收的距离,加大与遮罩边缘的间距 */
155
- export const TRACK_EDGE_INSET = 10;
360
+ /** 布局沉降兜底:onShow / onResize 后的重挂载延时 */
361
+ export const RELAYOUT_DELAYS = [80, 260];
156
362
 
157
- /** 竖屏样式微调 */
158
- export const STYLE_OPTIONS_BOTTOM = {
363
+ /**
364
+ * 竖屏样式微调。纵向几何跟着容器 16:9 推导,所以是函数而不是常量对象。
365
+ *
366
+ * `contentOffsetY: 0`:椭圆截面已经精确等于可见高度,再整体下移会把底边那一截裁掉。
367
+ *
368
+ * 底部触摸余量按布局给:竖屏全屏传 0(canvas 底边就是工具条顶边,再留余量会盖住按钮
369
+ * 并吃掉它们的点击);普通竖屏工具条已藏,保持上下对称、由 dock 的负 bottom 沉出去。
370
+ * (早先无条件设 0 时手势整体失灵,根因不在它,而是 rjs/index.js 漏读 ellipseRx/ellipseRy
371
+ * + absolutePosition 的 rpx 单位,两处都已修。)
372
+ */
373
+ export const getStyleOptionsBottom = isVerticalFull => _objectSpread({
159
374
  topSafeArea: 30,
160
375
  currentLabelFontSize: 12,
161
376
  markLabelFontSize: 10,
162
- contentOffsetY: CONTENT_OFFSET_Y,
163
- trackEdgeInset: TRACK_EDGE_INSET,
377
+ contentOffsetY: 0,
378
+ trackEdgeInset: TRACK_EDGE_INSET_BOTTOM,
379
+ trackRx: getTrackRxBottom(),
380
+ labelTopGap: LABEL_TOP_GAP_BOTTOM,
381
+ ellipseRx: getEllipseRxBottom(),
382
+ ellipseRy: getEllipseRyBottom(),
164
383
  hitPaddingX: HIT_PADDING_X,
165
384
  hitPaddingY: HIT_PADDING_Y
166
- };
385
+ }, isVerticalFull ? {
386
+ hitPaddingBottom: 0
387
+ } : null);
167
388
 
168
389
  /** 横屏样式微调:压小刻度、文字与指针,避免与右侧控件重叠 */
169
390
  export const STYLE_OPTIONS_RIGHT = {
@@ -174,19 +395,65 @@ export const STYLE_OPTIONS_RIGHT = {
174
395
  innerLabelOffsetY: 36,
175
396
  // 横屏画布整体旋转 90°,这里的偏移沿旋转后坐标系生效(即向屏幕右侧)
176
397
  contentOffsetY: CONTENT_OFFSET_Y,
177
- trackEdgeInset: TRACK_EDGE_INSET,
398
+ // 横屏不传 ellipseRy,沿用设计稿的 175.5:右侧空间够,无需压扁
399
+ trackEdgeInset: TRACK_EDGE_INSET_RIGHT,
178
400
  hitPaddingX: HIT_PADDING_X,
179
401
  hitPaddingY: HIT_PADDING_Y
180
402
  };
181
403
 
182
404
  /**
183
- * 普通刻度震动节流(ms)。step=0.1 时整个区间有几十级,
184
- * 不节流会震得过密,反而糊成一片没有颗粒感。
405
+ * 普通刻度震动的最小间隔(ms)。
406
+ *
407
+ * 60ms 太密:拖得稍快时 onChange 就是逐帧回调,等于每秒震十几下,马达来不及回位,
408
+ * 手上是一片持续嗡鸣而不是逐格咔哒。取 110ms 接近人手能分辨两次震动的下限,
409
+ * 也和连续下发的节流(80ms)拉开,不至于两个节奏互相打架。
410
+ */
411
+ export const VIBRATE_THROTTLE = 110;
412
+
413
+ /**
414
+ * 判定「拖得很快」的跨格数:一次 onChange 跨过的刻度数超过它,就不再逐格震。
415
+ *
416
+ * 快速甩动时用户根本分辨不出逐格反馈,继续震只会糊成一片;这时只保留跨主刻度的
417
+ * medium 挡位感,反而更清楚自己甩过了哪几档。
418
+ */
419
+ export const VIBRATE_FAST_STEPS = 3;
420
+
421
+ /**
422
+ * 内部调试开关:置 true 时忽略「只提示一次」的本地记录,每次出流都展示圈选引导,
423
+ * 点「知道了」也不写入已读标记 —— 便于反复核对引导的位置与样式。
424
+ *
425
+ * 只在自测引导样式时临时打开,**提交前必须回到 false**。
426
+ * 与 useLensZoom 里的 USE_MOCK_ZOOM_SKILL 是两个独立开关:
427
+ * 设备没有 ipc_multi_zoom 时入口本身就不渲染,引导自然也不会出现,那种情况要一起打开。
428
+ */
429
+ export const FORCE_SHOW_ZOOM_TIP = false;
430
+
431
+ /**
432
+ * 圈选提示:圈选框在入口 rect 四周额外留的内边距。
433
+ * 入口是横向的倍率胶囊(宽度随镜头数变化),圈选框按实测 rect 自适应,
434
+ * 不能用固定尺寸 —— 固定值既框不住宽度,上下又会超出。
435
+ */
436
+ export const TIP_RING_PADDING = 6;
437
+
438
+ /** 圈选提示:圈选框圆角,与 multiCamera 的 tile-tip 保持一致 */
439
+ export const TIP_RING_RADIUS = 12;
440
+
441
+ /** 圈选提示:引线总长(虚线 17 + 末端圆点 4),比 multiCamera 短,文案更贴近圈选处 */
442
+ export const TIP_LINE_TOTAL = 21;
443
+
444
+ /** 圈选提示:文案 + 按钮的面板宽度与距播放器边缘的最小间距 */
445
+ export const TIP_PANEL_WIDTH = 220;
446
+ export const TIP_PANEL_EDGE_GAP = 16;
447
+
448
+ /**
449
+ * 圈选提示:出流后延迟多久再测量并展示(ms)。
450
+ * 刚 PLAYING 时左下角元素还在陆续渲染、宽度还在长,等布局沉降再画圈才不会歪;
451
+ * 也顺带避免重连一恢复引导就弹出来。
185
452
  */
186
- export const VIBRATE_THROTTLE = 60;
453
+ export const TIP_RESHOW_DELAY = 500;
187
454
 
188
- /** 圈选提示:圆环尺寸 */
189
- export const TIP_RING_SIZE = 44;
455
+ /** 圈选提示:引线末端到文案面板的间距 */
456
+ export const TIP_PANEL_LINE_GAP = 8;
190
457
 
191
458
  /** 可视区判定:避开左下角滚动容器右侧淡出带的安全距离 */
192
459
  export const FADE_SAFE_PADDING = 24;
@@ -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);
@@ -18,5 +18,14 @@ export type AnchorRect = {
18
18
  * @returns 可见时返回入口相对视口的 rect,不可见 / 测不到返回 null
19
19
  */
20
20
  export declare const measureEntryVisible: () => Promise<AnchorRect | null>;
21
+ /**
22
+ * 竖屏底部工具条的实测高度(px),测不到返回 null。
23
+ *
24
+ * 转盘在竖屏全屏下要停在工具条之上,而这条的高度不止 ui.tsx 里写的 48px ——
25
+ * `-full` 变体还叠了 `padding-bottom: env(safe-area-inset-bottom)`,实际占位随机型变化。
26
+ * 用实测值定位,才不会压住右下角那排按钮(canvas 在 CoverView 里,层级高于按钮,
27
+ * 一压上去就把点击也吃掉了)。
28
+ */
29
+ export declare const measureBottomBarHeight: () => Promise<number | null>;
21
30
  /** 播放器容器 rect,用于把入口的视口坐标换算成容器内坐标 */
22
31
  export declare const measurePlayerRect: () => Promise<AnchorRect | null>;
@@ -62,6 +62,16 @@ export const measureEntryVisible = async () => {
62
62
  return null;
63
63
  };
64
64
 
65
+ /**
66
+ * 竖屏底部工具条的实测高度(px),测不到返回 null。
67
+ *
68
+ * 转盘在竖屏全屏下要停在工具条之上,而这条的高度不止 ui.tsx 里写的 48px ——
69
+ * `-full` 变体还叠了 `padding-bottom: env(safe-area-inset-bottom)`,实际占位随机型变化。
70
+ * 用实测值定位,才不会压住右下角那排按钮(canvas 在 CoverView 里,层级高于按钮,
71
+ * 一压上去就把点击也吃掉了)。
72
+ */
73
+ export const measureBottomBarHeight = () => queryRect('.ipc-player-bottom-content').then(rect => (rect === null || rect === void 0 ? void 0 : rect.height) > 0 ? rect.height : null);
74
+
65
75
  /** 播放器容器 rect,用于把入口的视口坐标换算成容器内坐标 */
66
76
  export const measurePlayerRect = () => queryRect('.ipc-player-content').then(rect => {
67
77
  var _rect$right, _rect$bottom;
@@ -23,6 +23,7 @@ const ZoomTurntable = props => {
23
23
  onChangeEnd,
24
24
  onTouchStart,
25
25
  onTouchEnd,
26
+ onBlankTap,
26
27
  marks,
27
28
  ticks,
28
29
  curve = 1,
@@ -73,6 +74,10 @@ const ZoomTurntable = props => {
73
74
  bindturntableTouchEnd: () => {
74
75
  console.log('[zoomTurntable/logic] turntableTouchEnd');
75
76
  onTouchEnd === null || onTouchEnd === void 0 || onTouchEnd();
77
+ },
78
+ bindblankTap: () => {
79
+ console.log('[zoomTurntable/logic] blankTap');
80
+ onBlankTap === null || onBlankTap === void 0 || onBlankTap();
76
81
  }
77
82
  });
78
83
  };