@livepeer-frameworks/player-core 0.0.3

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.
Files changed (120) hide show
  1. package/dist/cjs/index.js +19493 -0
  2. package/dist/cjs/index.js.map +1 -0
  3. package/dist/esm/index.js +19398 -0
  4. package/dist/esm/index.js.map +1 -0
  5. package/dist/player.css +2140 -0
  6. package/dist/types/core/ABRController.d.ts +164 -0
  7. package/dist/types/core/CodecUtils.d.ts +54 -0
  8. package/dist/types/core/Disposable.d.ts +61 -0
  9. package/dist/types/core/EventEmitter.d.ts +73 -0
  10. package/dist/types/core/GatewayClient.d.ts +144 -0
  11. package/dist/types/core/InteractionController.d.ts +121 -0
  12. package/dist/types/core/LiveDurationProxy.d.ts +102 -0
  13. package/dist/types/core/MetaTrackManager.d.ts +220 -0
  14. package/dist/types/core/MistReporter.d.ts +163 -0
  15. package/dist/types/core/MistSignaling.d.ts +148 -0
  16. package/dist/types/core/PlayerController.d.ts +665 -0
  17. package/dist/types/core/PlayerInterface.d.ts +230 -0
  18. package/dist/types/core/PlayerManager.d.ts +182 -0
  19. package/dist/types/core/PlayerRegistry.d.ts +27 -0
  20. package/dist/types/core/QualityMonitor.d.ts +184 -0
  21. package/dist/types/core/ScreenWakeLockManager.d.ts +70 -0
  22. package/dist/types/core/SeekingUtils.d.ts +142 -0
  23. package/dist/types/core/StreamStateClient.d.ts +108 -0
  24. package/dist/types/core/SubtitleManager.d.ts +111 -0
  25. package/dist/types/core/TelemetryReporter.d.ts +79 -0
  26. package/dist/types/core/TimeFormat.d.ts +97 -0
  27. package/dist/types/core/TimerManager.d.ts +83 -0
  28. package/dist/types/core/UrlUtils.d.ts +81 -0
  29. package/dist/types/core/detector.d.ts +149 -0
  30. package/dist/types/core/index.d.ts +49 -0
  31. package/dist/types/core/scorer.d.ts +167 -0
  32. package/dist/types/core/selector.d.ts +9 -0
  33. package/dist/types/index.d.ts +45 -0
  34. package/dist/types/lib/utils.d.ts +2 -0
  35. package/dist/types/players/DashJsPlayer.d.ts +102 -0
  36. package/dist/types/players/HlsJsPlayer.d.ts +70 -0
  37. package/dist/types/players/MewsWsPlayer/SourceBufferManager.d.ts +119 -0
  38. package/dist/types/players/MewsWsPlayer/WebSocketManager.d.ts +60 -0
  39. package/dist/types/players/MewsWsPlayer/index.d.ts +220 -0
  40. package/dist/types/players/MewsWsPlayer/types.d.ts +89 -0
  41. package/dist/types/players/MistPlayer.d.ts +25 -0
  42. package/dist/types/players/MistWebRTCPlayer/index.d.ts +133 -0
  43. package/dist/types/players/NativePlayer.d.ts +143 -0
  44. package/dist/types/players/VideoJsPlayer.d.ts +59 -0
  45. package/dist/types/players/WebCodecsPlayer/JitterBuffer.d.ts +118 -0
  46. package/dist/types/players/WebCodecsPlayer/LatencyProfiles.d.ts +64 -0
  47. package/dist/types/players/WebCodecsPlayer/RawChunkParser.d.ts +63 -0
  48. package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +174 -0
  49. package/dist/types/players/WebCodecsPlayer/WebSocketController.d.ts +164 -0
  50. package/dist/types/players/WebCodecsPlayer/index.d.ts +149 -0
  51. package/dist/types/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.d.ts +105 -0
  52. package/dist/types/players/WebCodecsPlayer/types.d.ts +395 -0
  53. package/dist/types/players/WebCodecsPlayer/worker/decoder.worker.d.ts +13 -0
  54. package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +197 -0
  55. package/dist/types/players/index.d.ts +14 -0
  56. package/dist/types/styles/index.d.ts +11 -0
  57. package/dist/types/types.d.ts +363 -0
  58. package/dist/types/vanilla/FrameWorksPlayer.d.ts +143 -0
  59. package/dist/types/vanilla/index.d.ts +19 -0
  60. package/dist/workers/decoder.worker.js +989 -0
  61. package/dist/workers/decoder.worker.js.map +1 -0
  62. package/package.json +80 -0
  63. package/src/core/ABRController.ts +550 -0
  64. package/src/core/CodecUtils.ts +257 -0
  65. package/src/core/Disposable.ts +120 -0
  66. package/src/core/EventEmitter.ts +113 -0
  67. package/src/core/GatewayClient.ts +439 -0
  68. package/src/core/InteractionController.ts +712 -0
  69. package/src/core/LiveDurationProxy.ts +270 -0
  70. package/src/core/MetaTrackManager.ts +753 -0
  71. package/src/core/MistReporter.ts +543 -0
  72. package/src/core/MistSignaling.ts +346 -0
  73. package/src/core/PlayerController.ts +2829 -0
  74. package/src/core/PlayerInterface.ts +432 -0
  75. package/src/core/PlayerManager.ts +900 -0
  76. package/src/core/PlayerRegistry.ts +149 -0
  77. package/src/core/QualityMonitor.ts +597 -0
  78. package/src/core/ScreenWakeLockManager.ts +163 -0
  79. package/src/core/SeekingUtils.ts +364 -0
  80. package/src/core/StreamStateClient.ts +457 -0
  81. package/src/core/SubtitleManager.ts +297 -0
  82. package/src/core/TelemetryReporter.ts +308 -0
  83. package/src/core/TimeFormat.ts +205 -0
  84. package/src/core/TimerManager.ts +209 -0
  85. package/src/core/UrlUtils.ts +179 -0
  86. package/src/core/detector.ts +382 -0
  87. package/src/core/index.ts +140 -0
  88. package/src/core/scorer.ts +553 -0
  89. package/src/core/selector.ts +16 -0
  90. package/src/global.d.ts +11 -0
  91. package/src/index.ts +75 -0
  92. package/src/lib/utils.ts +6 -0
  93. package/src/players/DashJsPlayer.ts +642 -0
  94. package/src/players/HlsJsPlayer.ts +483 -0
  95. package/src/players/MewsWsPlayer/SourceBufferManager.ts +572 -0
  96. package/src/players/MewsWsPlayer/WebSocketManager.ts +241 -0
  97. package/src/players/MewsWsPlayer/index.ts +1065 -0
  98. package/src/players/MewsWsPlayer/types.ts +106 -0
  99. package/src/players/MistPlayer.ts +188 -0
  100. package/src/players/MistWebRTCPlayer/index.ts +703 -0
  101. package/src/players/NativePlayer.ts +820 -0
  102. package/src/players/VideoJsPlayer.ts +643 -0
  103. package/src/players/WebCodecsPlayer/JitterBuffer.ts +299 -0
  104. package/src/players/WebCodecsPlayer/LatencyProfiles.ts +151 -0
  105. package/src/players/WebCodecsPlayer/RawChunkParser.ts +151 -0
  106. package/src/players/WebCodecsPlayer/SyncController.ts +456 -0
  107. package/src/players/WebCodecsPlayer/WebSocketController.ts +564 -0
  108. package/src/players/WebCodecsPlayer/index.ts +1650 -0
  109. package/src/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.ts +379 -0
  110. package/src/players/WebCodecsPlayer/types.ts +542 -0
  111. package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +1360 -0
  112. package/src/players/WebCodecsPlayer/worker/types.ts +276 -0
  113. package/src/players/index.ts +22 -0
  114. package/src/styles/animations.css +21 -0
  115. package/src/styles/index.ts +52 -0
  116. package/src/styles/player.css +2126 -0
  117. package/src/styles/tailwind.css +1015 -0
  118. package/src/types.ts +421 -0
  119. package/src/vanilla/FrameWorksPlayer.ts +367 -0
  120. package/src/vanilla/index.ts +22 -0
@@ -0,0 +1,712 @@
1
+ /**
2
+ * InteractionController - Unified keyboard and gesture handling for video players
3
+ *
4
+ * Features:
5
+ * - Hold space for 2x speed (VOD/clips only, tap = play/pause)
6
+ * - Click/touch and hold for 2x speed
7
+ * - Comprehensive keyboard shortcuts
8
+ * - Double-tap to skip on mobile
9
+ * - All interactions disabled for live streams (where applicable)
10
+ */
11
+
12
+ export interface InteractionControllerConfig {
13
+ container: HTMLElement;
14
+ videoElement: HTMLVideoElement;
15
+ isLive: boolean;
16
+ isPaused?: () => boolean;
17
+ onPlayPause: () => void;
18
+ onSeek: (delta: number) => void;
19
+ onVolumeChange: (delta: number) => void;
20
+ onMuteToggle: () => void;
21
+ onFullscreenToggle: () => void;
22
+ onCaptionsToggle?: () => void;
23
+ onLoopToggle?: () => void;
24
+ onSpeedChange: (speed: number, isHolding: boolean) => void;
25
+ onSeekPercent?: (percent: number) => void;
26
+ /** Optional: player-specific frame stepping (return true if handled) */
27
+ onFrameStep?: (direction: -1 | 1, seconds: number) => boolean | void;
28
+ speedHoldValue?: number;
29
+ /** Frame step duration in seconds (for prev/next frame shortcuts) */
30
+ frameStepSeconds?: number;
31
+ /** Idle timeout in ms (default 5000). Set to 0 to disable. */
32
+ idleTimeout?: number;
33
+ /** Callback fired when user becomes idle */
34
+ onIdle?: () => void;
35
+ /** Callback fired when user becomes active after being idle */
36
+ onActive?: () => void;
37
+ }
38
+
39
+ export interface InteractionState {
40
+ isHoldingSpeed: boolean;
41
+ previousSpeed: number;
42
+ holdSpeed: number;
43
+ /** Whether the user is currently idle (no interaction for idleTimeout) */
44
+ isIdle: boolean;
45
+ }
46
+
47
+ // Timing constants
48
+ const HOLD_THRESHOLD_MS = 200; // Time before keydown becomes "hold" vs "tap"
49
+ const LONG_PRESS_THRESHOLD_MS = 300; // Time for touch/click to become "hold"
50
+ const DOUBLE_TAP_WINDOW_MS = 300; // Window for detecting double-tap
51
+ const SKIP_AMOUNT_SECONDS = 10; // Skip forward/backward amount
52
+ const VOLUME_STEP = 0.1; // Volume change per arrow press (10%)
53
+ const DEFAULT_IDLE_TIMEOUT_MS = 5000; // Default idle timeout (5 seconds)
54
+
55
+ export class InteractionController {
56
+ private config: InteractionControllerConfig;
57
+ private state: InteractionState;
58
+ private isAttached = false;
59
+
60
+ // Keyboard tracking
61
+ private spaceKeyDownTime = 0;
62
+ private spaceIsHeld = false;
63
+ private holdCheckTimeout: ReturnType<typeof setTimeout> | null = null;
64
+
65
+ // Touch/click tracking
66
+ private pointerDownTime = 0;
67
+ private pointerIsHeld = false;
68
+ private pointerHoldTimeout: ReturnType<typeof setTimeout> | null = null;
69
+ private lastTapTime = 0;
70
+ private lastTapX = 0;
71
+ private pendingTapTimeout: ReturnType<typeof setTimeout> | null = null;
72
+
73
+ // Idle tracking
74
+ private idleTimeout: ReturnType<typeof setTimeout> | null = null;
75
+ private lastInteractionTime = 0;
76
+
77
+ // Bound event handlers
78
+ private boundKeyDown: (e: KeyboardEvent) => void;
79
+ private boundKeyUp: (e: KeyboardEvent) => void;
80
+ private boundPointerDown: (e: PointerEvent) => void;
81
+ private boundPointerUp: (e: PointerEvent) => void;
82
+ private boundPointerCancel: (e: PointerEvent) => void;
83
+ private boundContextMenu: (e: Event) => void;
84
+ private boundMouseMove: (e: MouseEvent) => void;
85
+ private boundDoubleClick: (e: MouseEvent) => void;
86
+ private boundDocumentKeyDown: (e: KeyboardEvent) => void;
87
+ private boundDocumentKeyUp: (e: KeyboardEvent) => void;
88
+
89
+ constructor(config: InteractionControllerConfig) {
90
+ this.config = config;
91
+ this.state = {
92
+ isHoldingSpeed: false,
93
+ previousSpeed: 1,
94
+ holdSpeed: config.speedHoldValue ?? 2,
95
+ isIdle: false,
96
+ };
97
+
98
+ // Bind handlers
99
+ this.boundKeyDown = this.handleKeyDown.bind(this);
100
+ this.boundKeyUp = this.handleKeyUp.bind(this);
101
+ this.boundPointerDown = this.handlePointerDown.bind(this);
102
+ this.boundPointerUp = this.handlePointerUp.bind(this);
103
+ this.boundPointerCancel = this.handlePointerCancel.bind(this);
104
+ this.boundContextMenu = this.handleContextMenu.bind(this);
105
+ this.boundMouseMove = this.handleMouseMove.bind(this);
106
+ this.boundDoubleClick = this.handleDoubleClick.bind(this);
107
+ this.boundDocumentKeyDown = this.handleKeyDown.bind(this);
108
+ this.boundDocumentKeyUp = this.handleKeyUp.bind(this);
109
+ }
110
+
111
+ /**
112
+ * Attach event listeners to container
113
+ */
114
+ attach(): void {
115
+ if (this.isAttached) return;
116
+
117
+ const { container } = this.config;
118
+
119
+ // Make container focusable for keyboard events
120
+ if (!container.hasAttribute('tabindex')) {
121
+ container.setAttribute('tabindex', '0');
122
+ }
123
+
124
+ // Keyboard events
125
+ container.addEventListener('keydown', this.boundKeyDown);
126
+ container.addEventListener('keyup', this.boundKeyUp);
127
+ document.addEventListener('keydown', this.boundDocumentKeyDown);
128
+ document.addEventListener('keyup', this.boundDocumentKeyUp);
129
+
130
+ // Pointer events (unified mouse + touch)
131
+ container.addEventListener('pointerdown', this.boundPointerDown);
132
+ container.addEventListener('pointerup', this.boundPointerUp);
133
+ container.addEventListener('pointercancel', this.boundPointerCancel);
134
+ container.addEventListener('pointerleave', this.boundPointerCancel);
135
+
136
+ // Mouse move for idle detection
137
+ container.addEventListener('mousemove', this.boundMouseMove);
138
+
139
+ // Double click for fullscreen (desktop)
140
+ container.addEventListener('dblclick', this.boundDoubleClick);
141
+
142
+ // Prevent context menu on long press
143
+ container.addEventListener('contextmenu', this.boundContextMenu);
144
+
145
+ // Start idle tracking
146
+ this.resetIdleTimer();
147
+
148
+ this.isAttached = true;
149
+ }
150
+
151
+ /**
152
+ * Detach event listeners and cleanup
153
+ */
154
+ detach(): void {
155
+ if (!this.isAttached) return;
156
+
157
+ const { container } = this.config;
158
+
159
+ container.removeEventListener('keydown', this.boundKeyDown);
160
+ container.removeEventListener('keyup', this.boundKeyUp);
161
+ document.removeEventListener('keydown', this.boundDocumentKeyDown);
162
+ document.removeEventListener('keyup', this.boundDocumentKeyUp);
163
+ container.removeEventListener('pointerdown', this.boundPointerDown);
164
+ container.removeEventListener('pointerup', this.boundPointerUp);
165
+ container.removeEventListener('pointercancel', this.boundPointerCancel);
166
+ container.removeEventListener('pointerleave', this.boundPointerCancel);
167
+ container.removeEventListener('mousemove', this.boundMouseMove);
168
+ container.removeEventListener('dblclick', this.boundDoubleClick);
169
+ container.removeEventListener('contextmenu', this.boundContextMenu);
170
+
171
+ // Clear any pending timeouts
172
+ if (this.holdCheckTimeout) {
173
+ clearTimeout(this.holdCheckTimeout);
174
+ this.holdCheckTimeout = null;
175
+ }
176
+ if (this.pointerHoldTimeout) {
177
+ clearTimeout(this.pointerHoldTimeout);
178
+ this.pointerHoldTimeout = null;
179
+ }
180
+ if (this.pendingTapTimeout) {
181
+ clearTimeout(this.pendingTapTimeout);
182
+ this.pendingTapTimeout = null;
183
+ }
184
+ if (this.idleTimeout) {
185
+ clearTimeout(this.idleTimeout);
186
+ this.idleTimeout = null;
187
+ }
188
+
189
+ // Restore speed if holding
190
+ if (this.state.isHoldingSpeed) {
191
+ this.releaseSpeedHold();
192
+ }
193
+
194
+ this.isAttached = false;
195
+ }
196
+
197
+ /**
198
+ * Check if currently holding for speed boost
199
+ */
200
+ isHoldingSpeed(): boolean {
201
+ return this.state.isHoldingSpeed;
202
+ }
203
+
204
+ /**
205
+ * Check if user is currently idle (no interaction for idleTimeout)
206
+ */
207
+ isIdle(): boolean {
208
+ return this.state.isIdle;
209
+ }
210
+
211
+ /**
212
+ * Get current interaction state
213
+ */
214
+ getState(): InteractionState {
215
+ return { ...this.state };
216
+ }
217
+
218
+ /**
219
+ * Update config (e.g., when isLive changes)
220
+ */
221
+ updateConfig(updates: Partial<InteractionControllerConfig>): void {
222
+ this.config = { ...this.config, ...updates };
223
+
224
+ // If we switched to live mode while holding, release
225
+ if (updates.isLive && this.state.isHoldingSpeed) {
226
+ this.releaseSpeedHold();
227
+ }
228
+ }
229
+
230
+ // ─────────────────────────────────────────────────────────────────
231
+ // Keyboard Handling
232
+ // ─────────────────────────────────────────────────────────────────
233
+
234
+ private handleKeyDown(e: KeyboardEvent): void {
235
+ // Ignore if focus is on an input element
236
+ if (this.isInputElement(e.target)) return;
237
+ if (e.defaultPrevented) return;
238
+ if (!this.shouldHandleKeyboard(e)) return;
239
+
240
+ // Record interaction for idle detection
241
+ this.recordInteraction();
242
+
243
+ const { isLive } = this.config;
244
+ const isPaused = this.config.isPaused?.() ?? this.config.videoElement?.paused ?? false;
245
+
246
+ switch (e.key) {
247
+ case ' ':
248
+ case 'Spacebar':
249
+ e.preventDefault();
250
+ this.handleSpaceDown();
251
+ break;
252
+
253
+ case 'ArrowLeft':
254
+ case 'j':
255
+ case 'J':
256
+ e.preventDefault();
257
+ if (!isLive) {
258
+ this.config.onSeek(-SKIP_AMOUNT_SECONDS);
259
+ }
260
+ break;
261
+
262
+ case 'ArrowRight':
263
+ case 'l':
264
+ case 'L':
265
+ e.preventDefault();
266
+ if (!isLive) {
267
+ this.config.onSeek(SKIP_AMOUNT_SECONDS);
268
+ }
269
+ break;
270
+
271
+ case 'ArrowUp':
272
+ e.preventDefault();
273
+ this.config.onVolumeChange(VOLUME_STEP);
274
+ break;
275
+
276
+ case 'ArrowDown':
277
+ e.preventDefault();
278
+ this.config.onVolumeChange(-VOLUME_STEP);
279
+ break;
280
+
281
+ case 'm':
282
+ case 'M':
283
+ e.preventDefault();
284
+ this.config.onMuteToggle();
285
+ break;
286
+
287
+ case 'f':
288
+ case 'F':
289
+ e.preventDefault();
290
+ this.config.onFullscreenToggle();
291
+ break;
292
+
293
+ case 'c':
294
+ case 'C':
295
+ e.preventDefault();
296
+ this.config.onCaptionsToggle?.();
297
+ break;
298
+
299
+ case 'k':
300
+ case 'K':
301
+ // YouTube-style: K = play/pause (no hold behavior)
302
+ e.preventDefault();
303
+ this.config.onPlayPause();
304
+ break;
305
+
306
+ case '<':
307
+ // Decrease speed (shift+, = <)
308
+ e.preventDefault();
309
+ if (!isLive) {
310
+ this.adjustPlaybackSpeed(-0.25);
311
+ }
312
+ break;
313
+
314
+ case '>':
315
+ // Increase speed (shift+. = >)
316
+ e.preventDefault();
317
+ if (!isLive) {
318
+ this.adjustPlaybackSpeed(0.25);
319
+ }
320
+ break;
321
+
322
+ case ',':
323
+ // Previous frame when paused
324
+ if (this.config.onFrameStep || (!isLive && isPaused)) {
325
+ e.preventDefault();
326
+ this.stepFrame(-1);
327
+ }
328
+ break;
329
+
330
+ case '.':
331
+ // Next frame when paused
332
+ if (this.config.onFrameStep || (!isLive && isPaused)) {
333
+ e.preventDefault();
334
+ this.stepFrame(1);
335
+ }
336
+ break;
337
+
338
+ // Number keys for seeking to percentage
339
+ case '0':
340
+ case '1':
341
+ case '2':
342
+ case '3':
343
+ case '4':
344
+ case '5':
345
+ case '6':
346
+ case '7':
347
+ case '8':
348
+ case '9':
349
+ e.preventDefault();
350
+ if (!isLive && this.config.onSeekPercent) {
351
+ const percent = parseInt(e.key, 10) / 10;
352
+ this.config.onSeekPercent(percent);
353
+ }
354
+ break;
355
+ }
356
+ }
357
+
358
+ private handleKeyUp(e: KeyboardEvent): void {
359
+ if (this.isInputElement(e.target)) return;
360
+ if (e.defaultPrevented) return;
361
+ if (!this.shouldHandleKeyboard(e)) return;
362
+
363
+ if (e.key === ' ' || e.key === 'Spacebar') {
364
+ e.preventDefault();
365
+ this.handleSpaceUp();
366
+ }
367
+ }
368
+
369
+ private shouldHandleKeyboard(e: KeyboardEvent): boolean {
370
+ if (this.spaceKeyDownTime > 0) return true;
371
+ const target = e.target as HTMLElement | null;
372
+ if (target && this.config.container.contains(target)) return true;
373
+ const active = document.activeElement as HTMLElement | null;
374
+ if (active && this.config.container.contains(active)) return true;
375
+ try {
376
+ if (this.config.container.matches(':focus-within')) return true;
377
+ if (this.config.container.matches(':hover')) return true;
378
+ } catch {}
379
+ const now = Date.now();
380
+ if (now - this.lastInteractionTime < DEFAULT_IDLE_TIMEOUT_MS) return true;
381
+ return false;
382
+ }
383
+
384
+ private handleSpaceDown(): void {
385
+ if (this.spaceKeyDownTime > 0) return; // Already tracking
386
+
387
+ this.spaceKeyDownTime = Date.now();
388
+ this.spaceIsHeld = false;
389
+
390
+ // Only enable hold-for-speed on VOD/clips
391
+ if (!this.config.isLive) {
392
+ this.holdCheckTimeout = setTimeout(() => {
393
+ this.spaceIsHeld = true;
394
+ this.engageSpeedHold();
395
+ }, HOLD_THRESHOLD_MS);
396
+ }
397
+ }
398
+
399
+ private handleSpaceUp(): void {
400
+ const downTime = this.spaceKeyDownTime;
401
+ this.spaceKeyDownTime = 0;
402
+
403
+ if (this.holdCheckTimeout) {
404
+ clearTimeout(this.holdCheckTimeout);
405
+ this.holdCheckTimeout = null;
406
+ }
407
+
408
+ if (this.spaceIsHeld) {
409
+ // Was holding - release speed boost
410
+ this.releaseSpeedHold();
411
+ this.spaceIsHeld = false;
412
+ } else {
413
+ // Was a quick tap - toggle play/pause
414
+ const elapsed = Date.now() - downTime;
415
+ if (elapsed < HOLD_THRESHOLD_MS || this.config.isLive) {
416
+ this.config.onPlayPause();
417
+ }
418
+ }
419
+ }
420
+
421
+ private handleDoubleClick(e: MouseEvent): void {
422
+ if (this.isControlElement(e.target)) return;
423
+ this.recordInteraction();
424
+ e.preventDefault();
425
+ this.config.onFullscreenToggle();
426
+ }
427
+
428
+ private stepFrame(direction: -1 | 1): void {
429
+ const step = this.getFrameStepSeconds();
430
+ if (!Number.isFinite(step) || step <= 0) return;
431
+ if (this.config.onFrameStep?.(direction, step)) return;
432
+ const video = this.config.videoElement;
433
+ if (!video) return;
434
+
435
+ const target = video.currentTime + (direction * step);
436
+ if (!Number.isFinite(target)) return;
437
+
438
+ // Only step within already-buffered ranges to avoid network seeks
439
+ const buffered = video.buffered;
440
+ if (buffered && buffered.length > 0) {
441
+ for (let i = 0; i < buffered.length; i++) {
442
+ const start = buffered.start(i);
443
+ const end = buffered.end(i);
444
+ if (target >= start && target <= end) {
445
+ try { video.currentTime = target; } catch {}
446
+ return;
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ // ─────────────────────────────────────────────────────────────────
453
+ // Pointer (Mouse/Touch) Handling
454
+ // ─────────────────────────────────────────────────────────────────
455
+
456
+ private handlePointerDown(e: PointerEvent): void {
457
+ // Only handle primary button / single touch on the video area
458
+ if (e.button !== 0) return;
459
+ if (this.isControlElement(e.target)) return;
460
+
461
+ // Record interaction for idle detection
462
+ this.recordInteraction();
463
+
464
+ // Ensure container has focus for keyboard events
465
+ this.config.container.focus();
466
+
467
+ const now = Date.now();
468
+ const rect = this.config.container.getBoundingClientRect();
469
+ const relativeX = (e.clientX - rect.left) / rect.width;
470
+ const isMouse = e.pointerType === 'mouse';
471
+
472
+ // Check for double-tap
473
+ if (now - this.lastTapTime < DOUBLE_TAP_WINDOW_MS) {
474
+ // Clear pending single-tap
475
+ if (this.pendingTapTimeout) {
476
+ clearTimeout(this.pendingTapTimeout);
477
+ this.pendingTapTimeout = null;
478
+ }
479
+
480
+ // Mouse double-click handled via dblclick event (fullscreen)
481
+ if (!isMouse) {
482
+ // Handle double-tap to skip (mobile-style)
483
+ if (!this.config.isLive) {
484
+ if (relativeX < 0.33) {
485
+ // Left third - skip back
486
+ this.config.onSeek(-SKIP_AMOUNT_SECONDS);
487
+ } else if (relativeX > 0.67) {
488
+ // Right third - skip forward
489
+ this.config.onSeek(SKIP_AMOUNT_SECONDS);
490
+ } else {
491
+ // Center - treat as play/pause
492
+ this.config.onPlayPause();
493
+ }
494
+ }
495
+ }
496
+
497
+ this.lastTapTime = 0;
498
+ return;
499
+ }
500
+
501
+ this.lastTapTime = now;
502
+ this.lastTapX = relativeX;
503
+ this.pointerDownTime = now;
504
+ this.pointerIsHeld = false;
505
+
506
+ // Start long-press detection for 2x speed (VOD only)
507
+ if (!this.config.isLive) {
508
+ this.pointerHoldTimeout = setTimeout(() => {
509
+ this.pointerIsHeld = true;
510
+ this.engageSpeedHold();
511
+ }, LONG_PRESS_THRESHOLD_MS);
512
+ }
513
+ }
514
+
515
+ private handlePointerUp(e: PointerEvent): void {
516
+ if (e.button !== 0) return;
517
+
518
+ const wasHeld = this.pointerIsHeld;
519
+ this.cancelPointerHold();
520
+
521
+ if (wasHeld) {
522
+ // Was long-pressing - just release speed
523
+ this.releaseSpeedHold();
524
+ } else if (this.pointerDownTime > 0) {
525
+ // Was a quick tap - delay to check for double-tap
526
+ this.pendingTapTimeout = setTimeout(() => {
527
+ this.pendingTapTimeout = null;
528
+ this.config.onPlayPause();
529
+ }, DOUBLE_TAP_WINDOW_MS);
530
+ }
531
+
532
+ this.pointerDownTime = 0;
533
+ }
534
+
535
+ private handlePointerCancel(_e: PointerEvent): void {
536
+ if (this.pointerIsHeld) {
537
+ this.releaseSpeedHold();
538
+ }
539
+ this.cancelPointerHold();
540
+ this.pointerDownTime = 0;
541
+ }
542
+
543
+ private cancelPointerHold(): void {
544
+ if (this.pointerHoldTimeout) {
545
+ clearTimeout(this.pointerHoldTimeout);
546
+ this.pointerHoldTimeout = null;
547
+ }
548
+ this.pointerIsHeld = false;
549
+ }
550
+
551
+ private handleContextMenu(e: Event): void {
552
+ // Prevent context menu during long-press
553
+ if (this.pointerIsHeld || this.pointerDownTime > 0) {
554
+ e.preventDefault();
555
+ }
556
+ }
557
+
558
+ // ─────────────────────────────────────────────────────────────────
559
+ // Speed Hold Logic
560
+ // ─────────────────────────────────────────────────────────────────
561
+
562
+ private engageSpeedHold(): void {
563
+ if (this.state.isHoldingSpeed) return;
564
+ if (this.config.isLive) return;
565
+
566
+ // Save current speed
567
+ this.state.previousSpeed = this.config.videoElement.playbackRate;
568
+ this.state.isHoldingSpeed = true;
569
+
570
+ // Apply hold speed
571
+ this.config.onSpeedChange(this.state.holdSpeed, true);
572
+ }
573
+
574
+ private releaseSpeedHold(): void {
575
+ if (!this.state.isHoldingSpeed) return;
576
+
577
+ this.state.isHoldingSpeed = false;
578
+
579
+ // Restore previous speed
580
+ this.config.onSpeedChange(this.state.previousSpeed, false);
581
+ }
582
+
583
+ private adjustPlaybackSpeed(delta: number): void {
584
+ if (this.state.isHoldingSpeed) return;
585
+
586
+ const currentSpeed = this.config.videoElement.playbackRate;
587
+ const newSpeed = Math.max(0.25, Math.min(4, currentSpeed + delta));
588
+
589
+ // Round to avoid floating point issues
590
+ const roundedSpeed = Math.round(newSpeed * 100) / 100;
591
+
592
+ this.config.onSpeedChange(roundedSpeed, false);
593
+ }
594
+
595
+ // ─────────────────────────────────────────────────────────────────
596
+ // Idle Detection
597
+ // ─────────────────────────────────────────────────────────────────
598
+
599
+ private handleMouseMove(_e: MouseEvent): void {
600
+ this.recordInteraction();
601
+ }
602
+
603
+ /**
604
+ * Record that an interaction occurred and reset idle timer
605
+ */
606
+ recordInteraction(): void {
607
+ this.lastInteractionTime = Date.now();
608
+
609
+ // If was idle, become active
610
+ if (this.state.isIdle) {
611
+ this.state.isIdle = false;
612
+ this.config.onActive?.();
613
+ }
614
+
615
+ // Reset idle timer
616
+ this.resetIdleTimer();
617
+ }
618
+
619
+ /**
620
+ * Reset the idle timer
621
+ */
622
+ private resetIdleTimer(): void {
623
+ // Clear existing timer
624
+ if (this.idleTimeout) {
625
+ clearTimeout(this.idleTimeout);
626
+ this.idleTimeout = null;
627
+ }
628
+
629
+ // Get timeout value (0 means disabled)
630
+ const timeout = this.config.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS;
631
+ if (timeout <= 0) return;
632
+
633
+ // Set new timer
634
+ this.idleTimeout = setTimeout(() => {
635
+ this.idleTimeout = null;
636
+ if (!this.state.isIdle) {
637
+ this.state.isIdle = true;
638
+ this.config.onIdle?.();
639
+ }
640
+ }, timeout);
641
+ }
642
+
643
+ /**
644
+ * Manually mark as active (e.g., when controls become visible)
645
+ */
646
+ markActive(): void {
647
+ this.recordInteraction();
648
+ }
649
+
650
+ /**
651
+ * Pause idle tracking (e.g., when controls are visible)
652
+ */
653
+ pauseIdleTracking(): void {
654
+ if (this.idleTimeout) {
655
+ clearTimeout(this.idleTimeout);
656
+ this.idleTimeout = null;
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Resume idle tracking
662
+ */
663
+ resumeIdleTracking(): void {
664
+ if (this.isAttached) {
665
+ this.resetIdleTimer();
666
+ }
667
+ }
668
+
669
+ // ─────────────────────────────────────────────────────────────────
670
+ // Utilities
671
+ // ─────────────────────────────────────────────────────────────────
672
+
673
+ private isInputElement(target: EventTarget | null): boolean {
674
+ if (!target || !(target instanceof HTMLElement)) return false;
675
+ const tagName = target.tagName.toLowerCase();
676
+ return tagName === 'input' || tagName === 'textarea' || tagName === 'select' || target.isContentEditable;
677
+ }
678
+
679
+ private isControlElement(target: EventTarget | null): boolean {
680
+ if (!target || !(target instanceof HTMLElement)) return false;
681
+
682
+ // Check if clicking on player controls (buttons, sliders, etc.)
683
+ const controlSelectors = [
684
+ 'button',
685
+ '[role="button"]',
686
+ '[role="slider"]',
687
+ 'input',
688
+ 'select',
689
+ '.fw-player-controls',
690
+ '[data-player-controls]',
691
+ '.fw-controls-wrapper',
692
+ '.fw-control-bar',
693
+ '.fw-settings-menu',
694
+ '.fw-context-menu',
695
+ '.fw-stats-panel',
696
+ '.fw-dev-panel',
697
+ '.fw-error-overlay',
698
+ '.fw-error-popup',
699
+ '.fw-player-error',
700
+ ];
701
+
702
+ return controlSelectors.some(selector => {
703
+ return target.matches(selector) || target.closest(selector) !== null;
704
+ });
705
+ }
706
+
707
+ private getFrameStepSeconds(): number {
708
+ const step = this.config.frameStepSeconds;
709
+ if (Number.isFinite(step) && (step as number) > 0) return step as number;
710
+ return 1 / 30;
711
+ }
712
+ }