@scarlett-player/gestures 1.6.0

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.
package/dist/index.cjs ADDED
@@ -0,0 +1,531 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_RECOGNIZER_OPTIONS: () => DEFAULT_RECOGNIZER_OPTIONS,
24
+ GestureOverlay: () => GestureOverlay,
25
+ createGesturesPlugin: () => createGesturesPlugin,
26
+ createRecognizer: () => createRecognizer,
27
+ default: () => index_default,
28
+ zoneFor: () => zoneFor
29
+ });
30
+ module.exports = __toCommonJS(index_exports);
31
+
32
+ // src/recognizer.ts
33
+ var ZONE_HYSTERESIS = 0.05;
34
+ var DEFAULT_RECOGNIZER_OPTIONS = {
35
+ doubleTapWindowMs: 275,
36
+ accumulationWindowMs: 650,
37
+ leftZone: 0.33,
38
+ rightZone: 0.33,
39
+ slopPx: 10
40
+ };
41
+ function zoneFor(fraction, options) {
42
+ if (fraction <= options.leftZone) return "left";
43
+ if (fraction >= 1 - options.rightZone) return "right";
44
+ return "middle";
45
+ }
46
+ function createRecognizer(options = {}) {
47
+ const config = { ...DEFAULT_RECOGNIZER_OPTIONS, ...options };
48
+ let pending = null;
49
+ let lastTapZone = null;
50
+ let lastTapAt = 0;
51
+ let accumulatingZone = null;
52
+ let accumulatedCount = 0;
53
+ let lastAccumulateAt = 0;
54
+ let activePointers = 0;
55
+ const resetSequence = () => {
56
+ lastTapZone = null;
57
+ lastTapAt = 0;
58
+ accumulatingZone = null;
59
+ accumulatedCount = 0;
60
+ lastAccumulateAt = 0;
61
+ };
62
+ const resolveZone = (fraction) => {
63
+ const raw = zoneFor(fraction, config);
64
+ const active = accumulatingZone ?? lastTapZone;
65
+ if (!active || raw === active) return raw;
66
+ if (active === "left" && fraction <= config.leftZone + ZONE_HYSTERESIS) return "left";
67
+ if (active === "right" && fraction >= 1 - config.rightZone - ZONE_HYSTERESIS) return "right";
68
+ return raw;
69
+ };
70
+ const expire = (now) => {
71
+ if (accumulatingZone && now - lastAccumulateAt > config.accumulationWindowMs) {
72
+ resetSequence();
73
+ } else if (!accumulatingZone && lastTapZone && now - lastTapAt > config.doubleTapWindowMs) {
74
+ lastTapZone = null;
75
+ lastTapAt = 0;
76
+ }
77
+ return [];
78
+ };
79
+ return {
80
+ handle(record) {
81
+ const events = [];
82
+ switch (record.type) {
83
+ case "down": {
84
+ activePointers += 1;
85
+ if (activePointers > 1) {
86
+ if (pending) pending.invalid = true;
87
+ if (accumulatingZone || lastTapZone) {
88
+ resetSequence();
89
+ events.push({ type: "cancel" });
90
+ }
91
+ return events;
92
+ }
93
+ expire(record.timeStamp);
94
+ pending = {
95
+ pointerId: record.pointerId,
96
+ x: record.x,
97
+ y: record.y,
98
+ fraction: record.fraction,
99
+ timeStamp: record.timeStamp,
100
+ invalid: false
101
+ };
102
+ return events;
103
+ }
104
+ case "move": {
105
+ if (!pending || pending.pointerId !== record.pointerId || pending.invalid) {
106
+ return events;
107
+ }
108
+ const dx = record.x - pending.x;
109
+ const dy = record.y - pending.y;
110
+ if (Math.hypot(dx, dy) > config.slopPx) {
111
+ pending.invalid = true;
112
+ if (accumulatingZone || lastTapZone) {
113
+ resetSequence();
114
+ events.push({ type: "cancel" });
115
+ }
116
+ }
117
+ return events;
118
+ }
119
+ case "up": {
120
+ activePointers = Math.max(0, activePointers - 1);
121
+ const candidate = pending;
122
+ pending = null;
123
+ if (!candidate || candidate.pointerId !== record.pointerId || candidate.invalid) {
124
+ return events;
125
+ }
126
+ expire(record.timeStamp);
127
+ const zone = resolveZone(record.fraction);
128
+ if (accumulatingZone) {
129
+ if (zone === accumulatingZone) {
130
+ accumulatedCount += 1;
131
+ lastAccumulateAt = record.timeStamp;
132
+ events.push({ type: "accumulate", zone, count: accumulatedCount });
133
+ } else {
134
+ resetSequence();
135
+ events.push({ type: "cancel" });
136
+ }
137
+ return events;
138
+ }
139
+ if (lastTapZone && zone === lastTapZone && record.timeStamp - lastTapAt <= config.doubleTapWindowMs) {
140
+ accumulatingZone = zone;
141
+ accumulatedCount = 1;
142
+ lastAccumulateAt = record.timeStamp;
143
+ lastTapZone = null;
144
+ lastTapAt = 0;
145
+ events.push({ type: "double-tap", zone, count: 1 });
146
+ return events;
147
+ }
148
+ lastTapZone = zone;
149
+ lastTapAt = record.timeStamp;
150
+ events.push({ type: "tap", zone });
151
+ return events;
152
+ }
153
+ case "cancel": {
154
+ activePointers = Math.max(0, activePointers - 1);
155
+ pending = null;
156
+ if (accumulatingZone || lastTapZone) {
157
+ resetSequence();
158
+ events.push({ type: "cancel" });
159
+ }
160
+ return events;
161
+ }
162
+ default:
163
+ return events;
164
+ }
165
+ },
166
+ tick(now) {
167
+ return expire(now);
168
+ },
169
+ reset() {
170
+ pending = null;
171
+ activePointers = 0;
172
+ resetSequence();
173
+ },
174
+ isAccumulating() {
175
+ return accumulatingZone !== null;
176
+ }
177
+ };
178
+ }
179
+
180
+ // src/overlay.ts
181
+ var STYLE_ID = "sp-gestures-styles";
182
+ var styles = `
183
+ .sp-gestures {
184
+ position: absolute;
185
+ top: 0;
186
+ left: 0;
187
+ right: 0;
188
+ /* The bottom strip belongs to the progress bar and control bar. */
189
+ bottom: 64px;
190
+ z-index: 6;
191
+ touch-action: manipulation;
192
+ user-select: none;
193
+ -webkit-user-select: none;
194
+ -webkit-touch-callout: none;
195
+ }
196
+
197
+ .sp-gestures__zone {
198
+ position: absolute;
199
+ top: 0;
200
+ bottom: 0;
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ pointer-events: none;
205
+ opacity: 0;
206
+ color: #fff;
207
+ transition: opacity 0.25s ease;
208
+ }
209
+
210
+ .sp-gestures__zone--left {
211
+ left: 0;
212
+ border-radius: 0 50% 50% 0;
213
+ }
214
+
215
+ .sp-gestures__zone--right {
216
+ right: 0;
217
+ border-radius: 50% 0 0 50%;
218
+ }
219
+
220
+ .sp-gestures__zone--active {
221
+ opacity: 1;
222
+ background: rgba(255, 255, 255, 0.12);
223
+ }
224
+
225
+ .sp-gestures__label {
226
+ font-size: 13px;
227
+ font-weight: 600;
228
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
229
+ }
230
+
231
+ .sp-gestures__live {
232
+ position: absolute;
233
+ width: 1px;
234
+ height: 1px;
235
+ margin: -1px;
236
+ padding: 0;
237
+ overflow: hidden;
238
+ clip: rect(0 0 0 0);
239
+ white-space: nowrap;
240
+ border: 0;
241
+ }
242
+
243
+ @media (prefers-reduced-motion: reduce) {
244
+ .sp-gestures__zone {
245
+ transition: none;
246
+ }
247
+ }
248
+ `;
249
+ var GestureOverlay = class {
250
+ constructor(container, options) {
251
+ this.container = container;
252
+ this.options = options;
253
+ this.styleEl = null;
254
+ this.hideTimer = null;
255
+ this.pointerHandler = (event) => {
256
+ if (event.pointerType !== "touch") return;
257
+ const rect = this.el.getBoundingClientRect();
258
+ const width = rect.width || 1;
259
+ this.options.onPointer({
260
+ type: event.type === "pointerdown" ? "down" : event.type === "pointermove" ? "move" : event.type === "pointerup" ? "up" : "cancel",
261
+ x: event.clientX,
262
+ y: event.clientY,
263
+ fraction: Math.max(0, Math.min(1, (event.clientX - rect.left) / width)),
264
+ pointerId: event.pointerId,
265
+ timeStamp: event.timeStamp
266
+ });
267
+ };
268
+ this.injectStyles();
269
+ this.el = document.createElement("div");
270
+ this.el.className = "sp-gestures";
271
+ const left = this.createZone("left");
272
+ const right = this.createZone("right");
273
+ this.zones = { left: left.zone, right: right.zone };
274
+ this.labels = { left: left.label, right: right.label };
275
+ this.live = document.createElement("div");
276
+ this.live.className = "sp-gestures__live";
277
+ this.live.setAttribute("aria-live", "polite");
278
+ this.live.setAttribute("role", "status");
279
+ this.el.appendChild(left.zone);
280
+ this.el.appendChild(right.zone);
281
+ this.el.appendChild(this.live);
282
+ this.el.addEventListener("pointerdown", this.pointerHandler);
283
+ this.el.addEventListener("pointermove", this.pointerHandler);
284
+ this.el.addEventListener("pointerup", this.pointerHandler);
285
+ this.el.addEventListener("pointercancel", this.pointerHandler);
286
+ container.appendChild(this.el);
287
+ }
288
+ /** Size the zones to match the recognizer's split. */
289
+ setZoneWidths(left, right) {
290
+ this.zones.left.style.width = `${left * 100}%`;
291
+ this.zones.right.style.width = `${right * 100}%`;
292
+ }
293
+ /**
294
+ * Show the cumulative seek for a zone.
295
+ *
296
+ * @param zone - Which side was tapped
297
+ * @param seconds - Total seconds this sequence has moved
298
+ */
299
+ showSeek(zone, seconds) {
300
+ if (zone === "middle") return;
301
+ const direction = zone === "right" ? "forward" : "back";
302
+ this.live.textContent = `${seconds} seconds ${direction}`;
303
+ if (!this.options.feedback) return;
304
+ const target = this.zones[zone];
305
+ this.labels[zone].textContent = `${seconds} seconds`;
306
+ target.classList.add("sp-gestures__zone--active");
307
+ if (this.hideTimer) clearTimeout(this.hideTimer);
308
+ this.hideTimer = setTimeout(() => {
309
+ this.zones.left.classList.remove("sp-gestures__zone--active");
310
+ this.zones.right.classList.remove("sp-gestures__zone--active");
311
+ this.hideTimer = null;
312
+ }, 600);
313
+ }
314
+ /** Announce that a forward seek was refused because the viewer is at the live edge. */
315
+ announceLiveEdge() {
316
+ this.live.textContent = "Already at the live edge";
317
+ }
318
+ destroy() {
319
+ if (this.hideTimer) {
320
+ clearTimeout(this.hideTimer);
321
+ this.hideTimer = null;
322
+ }
323
+ this.el.removeEventListener("pointerdown", this.pointerHandler);
324
+ this.el.removeEventListener("pointermove", this.pointerHandler);
325
+ this.el.removeEventListener("pointerup", this.pointerHandler);
326
+ this.el.removeEventListener("pointercancel", this.pointerHandler);
327
+ this.el.remove();
328
+ this.styleEl?.remove();
329
+ this.styleEl = null;
330
+ }
331
+ /** Exposed for tests and for hosts that want to inspect the surface. */
332
+ getElement() {
333
+ return this.el;
334
+ }
335
+ createZone(side) {
336
+ const zone = document.createElement("div");
337
+ zone.className = `sp-gestures__zone sp-gestures__zone--${side}`;
338
+ zone.setAttribute("aria-hidden", "true");
339
+ const label = document.createElement("span");
340
+ label.className = "sp-gestures__label";
341
+ zone.appendChild(label);
342
+ return { zone, label };
343
+ }
344
+ injectStyles() {
345
+ if (document.getElementById(STYLE_ID)) return;
346
+ this.styleEl = document.createElement("style");
347
+ this.styleEl.id = STYLE_ID;
348
+ this.styleEl.textContent = styles;
349
+ document.head.appendChild(this.styleEl);
350
+ }
351
+ };
352
+
353
+ // src/index.ts
354
+ function hasCoarsePointer() {
355
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
356
+ return false;
357
+ }
358
+ try {
359
+ return window.matchMedia("(pointer: coarse)").matches;
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+ function createGesturesPlugin(config = {}) {
365
+ let api = null;
366
+ let overlay = null;
367
+ let recognizer = null;
368
+ let active = false;
369
+ let runSeconds = 0;
370
+ let pendingHide = null;
371
+ const seekSeconds = config.seekSeconds ?? 10;
372
+ const doubleTapWindowMs = config.doubleTapWindowMs ?? DEFAULT_RECOGNIZER_OPTIONS.doubleTapWindowMs;
373
+ const leftZone = config.zones?.left ?? DEFAULT_RECOGNIZER_OPTIONS.leftZone;
374
+ const rightZone = config.zones?.right ?? DEFAULT_RECOGNIZER_OPTIONS.rightZone;
375
+ const feedback = config.feedback !== false;
376
+ const haptics = config.haptics !== false;
377
+ const tapToToggleControls = config.tapToToggleControls !== false;
378
+ const canSeek = () => {
379
+ if (!api) return false;
380
+ if (api.getState("chromecastActive") || api.getState("airplayActive")) return false;
381
+ const live = api.getState("live");
382
+ const seekableRange = api.getState("seekableRange");
383
+ if (live && !seekableRange) return false;
384
+ const duration = api.getState("duration");
385
+ if (!live && (!duration || !Number.isFinite(duration))) return false;
386
+ return true;
387
+ };
388
+ const applySeek = (zone) => {
389
+ if (!api || zone === "middle") return false;
390
+ const video = api.container.querySelector("video");
391
+ if (!video) return false;
392
+ const delta = zone === "right" ? seekSeconds : -seekSeconds;
393
+ const live = api.getState("live");
394
+ const seekableRange = api.getState("seekableRange");
395
+ const current = video.currentTime;
396
+ let target;
397
+ if (live && seekableRange) {
398
+ target = Math.max(seekableRange.start, Math.min(seekableRange.end, current + delta));
399
+ if (zone === "right" && target <= current) {
400
+ overlay?.announceLiveEdge();
401
+ return false;
402
+ }
403
+ } else {
404
+ const duration = video.duration;
405
+ const max = Number.isFinite(duration) && duration > 0 ? duration - 0.25 : current;
406
+ target = Math.max(0, Math.min(max, current + delta));
407
+ }
408
+ video.currentTime = target;
409
+ api.emit("playback:seeking", { time: target });
410
+ if (haptics && typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
411
+ navigator.vibrate(10);
412
+ }
413
+ return true;
414
+ };
415
+ const ui = () => api?.getPlugin("ui-controls") ?? null;
416
+ const clearPendingHide = () => {
417
+ if (pendingHide) {
418
+ clearTimeout(pendingHide);
419
+ pendingHide = null;
420
+ }
421
+ };
422
+ const handleTap = () => {
423
+ if (!tapToToggleControls || !api) return;
424
+ const controls = ui();
425
+ if (!controls) return;
426
+ const visible = Boolean(api.getState("controlsVisible"));
427
+ const paused = Boolean(api.getState("paused"));
428
+ if (!visible) {
429
+ controls.show();
430
+ return;
431
+ }
432
+ if (paused) return;
433
+ clearPendingHide();
434
+ pendingHide = setTimeout(() => {
435
+ pendingHide = null;
436
+ controls.hide();
437
+ }, doubleTapWindowMs);
438
+ };
439
+ const handleSeekStep = (zone) => {
440
+ clearPendingHide();
441
+ if (!canSeek()) return;
442
+ const moved = applySeek(zone);
443
+ if (!moved) return;
444
+ runSeconds += seekSeconds;
445
+ overlay?.showSeek(zone, runSeconds);
446
+ api?.emit("gesture:seek", {
447
+ direction: zone === "right" ? "forward" : "backward",
448
+ seconds: seekSeconds,
449
+ cumulative: runSeconds
450
+ });
451
+ };
452
+ const onPointer = (record) => {
453
+ if (!recognizer) return;
454
+ for (const event of recognizer.handle(record)) {
455
+ switch (event.type) {
456
+ case "tap":
457
+ api?.emit("gesture:tap", { zone: event.zone });
458
+ handleTap();
459
+ break;
460
+ case "double-tap":
461
+ runSeconds = 0;
462
+ handleSeekStep(event.zone);
463
+ break;
464
+ case "accumulate":
465
+ handleSeekStep(event.zone);
466
+ break;
467
+ case "cancel":
468
+ runSeconds = 0;
469
+ clearPendingHide();
470
+ break;
471
+ }
472
+ }
473
+ };
474
+ return {
475
+ id: "gestures",
476
+ name: "Gestures",
477
+ version: "1.0.0",
478
+ type: "feature",
479
+ init(pluginApi) {
480
+ api = pluginApi;
481
+ const enabled = config.enabled ?? "auto";
482
+ active = enabled === "auto" ? hasCoarsePointer() : Boolean(enabled);
483
+ if (!active) {
484
+ api.logger.debug("[gestures] no coarse pointer, gesture surface not installed");
485
+ return;
486
+ }
487
+ if (api.getState("mediaType") === "audio") {
488
+ active = false;
489
+ return;
490
+ }
491
+ recognizer = createRecognizer({
492
+ doubleTapWindowMs,
493
+ accumulationWindowMs: config.accumulationWindowMs,
494
+ leftZone,
495
+ rightZone,
496
+ slopPx: config.slopPx
497
+ });
498
+ overlay = new GestureOverlay(api.container, { onPointer, feedback });
499
+ overlay.setZoneWidths(leftZone, rightZone);
500
+ api.onDestroy(() => {
501
+ clearPendingHide();
502
+ overlay?.destroy();
503
+ overlay = null;
504
+ recognizer?.reset();
505
+ recognizer = null;
506
+ });
507
+ },
508
+ destroy() {
509
+ clearPendingHide();
510
+ overlay?.destroy();
511
+ overlay = null;
512
+ recognizer?.reset();
513
+ recognizer = null;
514
+ active = false;
515
+ runSeconds = 0;
516
+ api = null;
517
+ },
518
+ ownsTapInteraction() {
519
+ return active && tapToToggleControls;
520
+ }
521
+ };
522
+ }
523
+ var index_default = createGesturesPlugin;
524
+ // Annotate the CommonJS export names for ESM import in node:
525
+ 0 && (module.exports = {
526
+ DEFAULT_RECOGNIZER_OPTIONS,
527
+ GestureOverlay,
528
+ createGesturesPlugin,
529
+ createRecognizer,
530
+ zoneFor
531
+ });