@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.
- package/dist/cjs/index.js +19493 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.js +19398 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/player.css +2140 -0
- package/dist/types/core/ABRController.d.ts +164 -0
- package/dist/types/core/CodecUtils.d.ts +54 -0
- package/dist/types/core/Disposable.d.ts +61 -0
- package/dist/types/core/EventEmitter.d.ts +73 -0
- package/dist/types/core/GatewayClient.d.ts +144 -0
- package/dist/types/core/InteractionController.d.ts +121 -0
- package/dist/types/core/LiveDurationProxy.d.ts +102 -0
- package/dist/types/core/MetaTrackManager.d.ts +220 -0
- package/dist/types/core/MistReporter.d.ts +163 -0
- package/dist/types/core/MistSignaling.d.ts +148 -0
- package/dist/types/core/PlayerController.d.ts +665 -0
- package/dist/types/core/PlayerInterface.d.ts +230 -0
- package/dist/types/core/PlayerManager.d.ts +182 -0
- package/dist/types/core/PlayerRegistry.d.ts +27 -0
- package/dist/types/core/QualityMonitor.d.ts +184 -0
- package/dist/types/core/ScreenWakeLockManager.d.ts +70 -0
- package/dist/types/core/SeekingUtils.d.ts +142 -0
- package/dist/types/core/StreamStateClient.d.ts +108 -0
- package/dist/types/core/SubtitleManager.d.ts +111 -0
- package/dist/types/core/TelemetryReporter.d.ts +79 -0
- package/dist/types/core/TimeFormat.d.ts +97 -0
- package/dist/types/core/TimerManager.d.ts +83 -0
- package/dist/types/core/UrlUtils.d.ts +81 -0
- package/dist/types/core/detector.d.ts +149 -0
- package/dist/types/core/index.d.ts +49 -0
- package/dist/types/core/scorer.d.ts +167 -0
- package/dist/types/core/selector.d.ts +9 -0
- package/dist/types/index.d.ts +45 -0
- package/dist/types/lib/utils.d.ts +2 -0
- package/dist/types/players/DashJsPlayer.d.ts +102 -0
- package/dist/types/players/HlsJsPlayer.d.ts +70 -0
- package/dist/types/players/MewsWsPlayer/SourceBufferManager.d.ts +119 -0
- package/dist/types/players/MewsWsPlayer/WebSocketManager.d.ts +60 -0
- package/dist/types/players/MewsWsPlayer/index.d.ts +220 -0
- package/dist/types/players/MewsWsPlayer/types.d.ts +89 -0
- package/dist/types/players/MistPlayer.d.ts +25 -0
- package/dist/types/players/MistWebRTCPlayer/index.d.ts +133 -0
- package/dist/types/players/NativePlayer.d.ts +143 -0
- package/dist/types/players/VideoJsPlayer.d.ts +59 -0
- package/dist/types/players/WebCodecsPlayer/JitterBuffer.d.ts +118 -0
- package/dist/types/players/WebCodecsPlayer/LatencyProfiles.d.ts +64 -0
- package/dist/types/players/WebCodecsPlayer/RawChunkParser.d.ts +63 -0
- package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +174 -0
- package/dist/types/players/WebCodecsPlayer/WebSocketController.d.ts +164 -0
- package/dist/types/players/WebCodecsPlayer/index.d.ts +149 -0
- package/dist/types/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.d.ts +105 -0
- package/dist/types/players/WebCodecsPlayer/types.d.ts +395 -0
- package/dist/types/players/WebCodecsPlayer/worker/decoder.worker.d.ts +13 -0
- package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +197 -0
- package/dist/types/players/index.d.ts +14 -0
- package/dist/types/styles/index.d.ts +11 -0
- package/dist/types/types.d.ts +363 -0
- package/dist/types/vanilla/FrameWorksPlayer.d.ts +143 -0
- package/dist/types/vanilla/index.d.ts +19 -0
- package/dist/workers/decoder.worker.js +989 -0
- package/dist/workers/decoder.worker.js.map +1 -0
- package/package.json +80 -0
- package/src/core/ABRController.ts +550 -0
- package/src/core/CodecUtils.ts +257 -0
- package/src/core/Disposable.ts +120 -0
- package/src/core/EventEmitter.ts +113 -0
- package/src/core/GatewayClient.ts +439 -0
- package/src/core/InteractionController.ts +712 -0
- package/src/core/LiveDurationProxy.ts +270 -0
- package/src/core/MetaTrackManager.ts +753 -0
- package/src/core/MistReporter.ts +543 -0
- package/src/core/MistSignaling.ts +346 -0
- package/src/core/PlayerController.ts +2829 -0
- package/src/core/PlayerInterface.ts +432 -0
- package/src/core/PlayerManager.ts +900 -0
- package/src/core/PlayerRegistry.ts +149 -0
- package/src/core/QualityMonitor.ts +597 -0
- package/src/core/ScreenWakeLockManager.ts +163 -0
- package/src/core/SeekingUtils.ts +364 -0
- package/src/core/StreamStateClient.ts +457 -0
- package/src/core/SubtitleManager.ts +297 -0
- package/src/core/TelemetryReporter.ts +308 -0
- package/src/core/TimeFormat.ts +205 -0
- package/src/core/TimerManager.ts +209 -0
- package/src/core/UrlUtils.ts +179 -0
- package/src/core/detector.ts +382 -0
- package/src/core/index.ts +140 -0
- package/src/core/scorer.ts +553 -0
- package/src/core/selector.ts +16 -0
- package/src/global.d.ts +11 -0
- package/src/index.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/players/DashJsPlayer.ts +642 -0
- package/src/players/HlsJsPlayer.ts +483 -0
- package/src/players/MewsWsPlayer/SourceBufferManager.ts +572 -0
- package/src/players/MewsWsPlayer/WebSocketManager.ts +241 -0
- package/src/players/MewsWsPlayer/index.ts +1065 -0
- package/src/players/MewsWsPlayer/types.ts +106 -0
- package/src/players/MistPlayer.ts +188 -0
- package/src/players/MistWebRTCPlayer/index.ts +703 -0
- package/src/players/NativePlayer.ts +820 -0
- package/src/players/VideoJsPlayer.ts +643 -0
- package/src/players/WebCodecsPlayer/JitterBuffer.ts +299 -0
- package/src/players/WebCodecsPlayer/LatencyProfiles.ts +151 -0
- package/src/players/WebCodecsPlayer/RawChunkParser.ts +151 -0
- package/src/players/WebCodecsPlayer/SyncController.ts +456 -0
- package/src/players/WebCodecsPlayer/WebSocketController.ts +564 -0
- package/src/players/WebCodecsPlayer/index.ts +1650 -0
- package/src/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.ts +379 -0
- package/src/players/WebCodecsPlayer/types.ts +542 -0
- package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +1360 -0
- package/src/players/WebCodecsPlayer/worker/types.ts +276 -0
- package/src/players/index.ts +22 -0
- package/src/styles/animations.css +21 -0
- package/src/styles/index.ts +52 -0
- package/src/styles/player.css +2126 -0
- package/src/styles/tailwind.css +1015 -0
- package/src/types.ts +421 -0
- package/src/vanilla/FrameWorksPlayer.ts +367 -0
- 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
|
+
}
|