@frameset/plex-player 1.0.5 → 2.0.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/src/core/index.js DELETED
@@ -1,950 +0,0 @@
1
- /**
2
- * @frameset/plex-player - Core Entry Point
3
- * Professional Video Player by FRAMESET Studio
4
- * https://frameset.dev
5
- */
6
-
7
- /**
8
- * Utility Functions
9
- */
10
- const Utils = {
11
- formatTime(seconds) {
12
- if (isNaN(seconds) || !isFinite(seconds)) return '0:00';
13
- seconds = Math.max(0, Math.floor(seconds));
14
- const hours = Math.floor(seconds / 3600);
15
- const minutes = Math.floor((seconds % 3600) / 60);
16
- const secs = seconds % 60;
17
- if (hours > 0) {
18
- return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
19
- }
20
- return `${minutes}:${secs.toString().padStart(2, '0')}`;
21
- },
22
-
23
- clamp(num, min, max) {
24
- return Math.min(Math.max(num, min), max);
25
- },
26
-
27
- throttle(fn, delay) {
28
- let lastCall = 0;
29
- return function (...args) {
30
- const now = Date.now();
31
- if (now - lastCall >= delay) {
32
- lastCall = now;
33
- return fn.apply(this, args);
34
- }
35
- };
36
- },
37
-
38
- debounce(fn, delay) {
39
- let timeoutId;
40
- return function (...args) {
41
- clearTimeout(timeoutId);
42
- timeoutId = setTimeout(() => fn.apply(this, args), delay);
43
- };
44
- }
45
- };
46
-
47
- /**
48
- * PlexPlayer - Main Player Class
49
- * A complete, standalone video player
50
- */
51
- class PlexPlayer {
52
- constructor(options = {}) {
53
- // Validate container
54
- if (!options.container) {
55
- throw new Error('PlexPlayer: container option is required');
56
- }
57
-
58
- // Get container element
59
- this.container = typeof options.container === 'string'
60
- ? document.querySelector(options.container)
61
- : options.container;
62
-
63
- if (!this.container) {
64
- throw new Error('PlexPlayer: container element not found');
65
- }
66
-
67
- // Store options with defaults
68
- this.options = {
69
- autoplay: false,
70
- muted: false,
71
- loop: false,
72
- volume: 1,
73
- poster: '',
74
- preload: 'metadata',
75
- keyboard: true,
76
- touch: true,
77
- pip: true,
78
- cast: true,
79
- fullscreen: true,
80
- controlsHideDelay: 3000,
81
- theme: {},
82
- subtitles: {},
83
- ads: { enabled: false },
84
- i18n: {},
85
- ...options,
86
- };
87
-
88
- // Internationalization with defaults
89
- this.i18n = {
90
- play: 'Play',
91
- pause: 'Pause',
92
- mute: 'Mute',
93
- unmute: 'Unmute',
94
- fullscreen: 'Fullscreen',
95
- exitFullscreen: 'Exit Fullscreen',
96
- pip: 'Picture-in-Picture',
97
- exitPip: 'Exit Picture-in-Picture',
98
- settings: 'Settings',
99
- speed: 'Speed',
100
- quality: 'Quality',
101
- subtitles: 'Subtitles',
102
- off: 'Off',
103
- normal: 'Normal',
104
- captions: 'Captions',
105
- audio: 'Audio',
106
- volume: 'Volume',
107
- seekForward: 'Seek Forward',
108
- seekBackward: 'Seek Backward',
109
- ...this.options.i18n,
110
- };
111
-
112
- // Internal state
113
- this._eventListeners = new Map();
114
- this._state = {
115
- playing: false,
116
- paused: true,
117
- muted: this.options.muted,
118
- volume: this.options.volume,
119
- currentTime: 0,
120
- duration: 0,
121
- buffered: 0,
122
- fullscreen: false,
123
- pip: false,
124
- playbackRate: 1,
125
- };
126
-
127
- // Playlist state
128
- this._playlist = [];
129
- this._currentIndex = 0;
130
-
131
- // Controls timeout
132
- this._controlsTimeout = null;
133
-
134
- // Initialize player
135
- this._init();
136
- }
137
-
138
- _init() {
139
- // Add player class
140
- this.container.classList.add('plex-player');
141
- // Show controls initially
142
- this.container.classList.add('plex-controls-visible');
143
-
144
- // Apply theme
145
- this._applyTheme();
146
-
147
- // Create DOM structure
148
- this._createDOM();
149
-
150
- // Bind events
151
- this._bindEvents();
152
-
153
- // Initialize features
154
- if (this.options.keyboard) this._initKeyboard();
155
- if (this.options.touch) this._initTouch();
156
- if (this.options.pip) this._initPiP();
157
- if (this.options.cast) this._initCast();
158
-
159
- // Emit ready event
160
- setTimeout(() => this._emit('ready'), 0);
161
- }
162
-
163
- _applyTheme() {
164
- const { theme } = this.options;
165
- if (theme.primary) this.container.style.setProperty('--plex-primary', theme.primary);
166
- if (theme.background) this.container.style.setProperty('--plex-bg', theme.background);
167
- if (theme.text) this.container.style.setProperty('--plex-text', theme.text);
168
- if (theme.borderRadius) this.container.style.setProperty('--plex-border-radius', theme.borderRadius);
169
- }
170
-
171
- _createDOM() {
172
- // Video element
173
- this.video = document.createElement('video');
174
- this.video.className = 'plex-video';
175
- this.video.setAttribute('playsinline', '');
176
- this.video.setAttribute('webkit-playsinline', '');
177
- this.video.preload = this.options.preload;
178
- this.video.muted = this.options.muted;
179
- this.video.volume = this.options.volume;
180
- this.video.loop = this.options.loop;
181
- if (this.options.poster) this.video.poster = this.options.poster;
182
- this.container.appendChild(this.video);
183
-
184
- // Controls overlay
185
- this.controls = document.createElement('div');
186
- this.controls.className = 'plex-controls';
187
- this.controls.innerHTML = this._getControlsHTML();
188
- this.container.appendChild(this.controls);
189
-
190
- // Cache control elements
191
- this._cacheElements();
192
-
193
- // Big play button
194
- this.bigPlayBtn = document.createElement('div');
195
- this.bigPlayBtn.className = 'plex-big-play';
196
- this.bigPlayBtn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`;
197
- this.container.appendChild(this.bigPlayBtn);
198
-
199
- // Loading spinner
200
- this.loader = document.createElement('div');
201
- this.loader.className = 'plex-loader';
202
- this.loader.innerHTML = '<div class="plex-spinner"></div>';
203
- this.container.appendChild(this.loader);
204
- }
205
-
206
- _getControlsHTML() {
207
- const i18n = this.i18n;
208
- return `
209
- <div class="plex-progress-container">
210
- <div class="plex-progress-bar">
211
- <div class="plex-progress-buffered"></div>
212
- <div class="plex-progress-played"></div>
213
- <div class="plex-progress-handle"></div>
214
- </div>
215
- <div class="plex-progress-preview">
216
- <div class="plex-preview-time">0:00</div>
217
- </div>
218
- </div>
219
- <div class="plex-controls-bar">
220
- <div class="plex-controls-left">
221
- <button class="plex-btn plex-play-btn" title="${i18n.play || 'Play'}">
222
- <svg class="plex-icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
223
- <svg class="plex-icon-pause" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
224
- </button>
225
- <button class="plex-btn plex-prev-btn" title="${i18n.previous || 'Previous'}">
226
- <svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
227
- </button>
228
- <button class="plex-btn plex-next-btn" title="${i18n.next || 'Next'}">
229
- <svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
230
- </button>
231
- <div class="plex-volume-container">
232
- <button class="plex-btn plex-volume-btn" title="${i18n.mute || 'Mute'}">
233
- <svg class="plex-icon-volume-high" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
234
- <svg class="plex-icon-volume-low" viewBox="0 0 24 24"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>
235
- <svg class="plex-icon-volume-mute" viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
236
- </button>
237
- <div class="plex-volume-slider">
238
- <div class="plex-volume-track">
239
- <div class="plex-volume-level"></div>
240
- <div class="plex-volume-handle"></div>
241
- </div>
242
- </div>
243
- </div>
244
- <div class="plex-time">
245
- <span class="plex-time-current">0:00</span>
246
- <span class="plex-time-separator">/</span>
247
- <span class="plex-time-duration">0:00</span>
248
- </div>
249
- </div>
250
- <div class="plex-controls-right">
251
- <button class="plex-btn plex-settings-btn" title="${i18n.settings || 'Settings'}">
252
- <svg viewBox="0 0 24 24"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
253
- </button>
254
- ${this.options.pip ? `
255
- <button class="plex-btn plex-pip-btn" title="${i18n.pip || 'Picture-in-Picture'}">
256
- <svg viewBox="0 0 24 24"><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z"/></svg>
257
- </button>
258
- ` : ''}
259
- ${this.options.cast ? `
260
- <button class="plex-btn plex-cast-btn" title="${i18n.cast || 'Cast'}">
261
- <svg viewBox="0 0 24 24"><path d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11z"/></svg>
262
- </button>
263
- ` : ''}
264
- ${this.options.fullscreen ? `
265
- <button class="plex-btn plex-fullscreen-btn" title="${i18n.fullscreen || 'Fullscreen'}">
266
- <svg class="plex-icon-fullscreen" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
267
- <svg class="plex-icon-fullscreen-exit" viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>
268
- </button>
269
- ` : ''}
270
- </div>
271
- </div>
272
- `;
273
- }
274
-
275
- _cacheElements() {
276
- const $ = (sel) => this.controls.querySelector(sel);
277
-
278
- this.els = {
279
- progressContainer: $('.plex-progress-container'),
280
- progressBar: $('.plex-progress-bar'),
281
- progressBuffered: $('.plex-progress-buffered'),
282
- progressPlayed: $('.plex-progress-played'),
283
- progressHandle: $('.plex-progress-handle'),
284
- progressPreview: $('.plex-progress-preview'),
285
- previewTime: $('.plex-preview-time'),
286
- playBtn: $('.plex-play-btn'),
287
- prevBtn: $('.plex-prev-btn'),
288
- nextBtn: $('.plex-next-btn'),
289
- volumeBtn: $('.plex-volume-btn'),
290
- volumeSlider: $('.plex-volume-slider'),
291
- volumeTrack: $('.plex-volume-track'),
292
- volumeLevel: $('.plex-volume-level'),
293
- volumeHandle: $('.plex-volume-handle'),
294
- timeCurrent: $('.plex-time-current'),
295
- timeDuration: $('.plex-time-duration'),
296
- settingsBtn: $('.plex-settings-btn'),
297
- pipBtn: $('.plex-pip-btn'),
298
- castBtn: $('.plex-cast-btn'),
299
- fullscreenBtn: $('.plex-fullscreen-btn'),
300
- };
301
- }
302
-
303
- _bindEvents() {
304
- // Video events
305
- this.video.addEventListener('play', () => this._onPlay());
306
- this.video.addEventListener('pause', () => this._onPause());
307
- this.video.addEventListener('ended', () => this._onEnded());
308
- this.video.addEventListener('timeupdate', () => this._onTimeUpdate());
309
- this.video.addEventListener('progress', () => this._onProgress());
310
- this.video.addEventListener('loadedmetadata', () => this._onLoadedMetadata());
311
- this.video.addEventListener('volumechange', () => this._onVolumeChange());
312
- this.video.addEventListener('waiting', () => this._onWaiting());
313
- this.video.addEventListener('canplay', () => this._onCanPlay());
314
- this.video.addEventListener('error', (e) => this._onError(e));
315
-
316
- // Control events
317
- this.bigPlayBtn.addEventListener('click', () => this.togglePlay());
318
- this.video.addEventListener('click', () => this.togglePlay());
319
-
320
- this.els.playBtn?.addEventListener('click', () => this.togglePlay());
321
- this.els.prevBtn?.addEventListener('click', () => this.previous());
322
- this.els.nextBtn?.addEventListener('click', () => this.next());
323
- this.els.volumeBtn?.addEventListener('click', () => this.toggleMute());
324
- this.els.pipBtn?.addEventListener('click', () => this.togglePiP());
325
- this.els.fullscreenBtn?.addEventListener('click', () => this.toggleFullscreen());
326
-
327
- // Progress bar
328
- this._bindProgressEvents();
329
-
330
- // Volume slider
331
- this._bindVolumeEvents();
332
-
333
- // Controls visibility
334
- this._bindControlsVisibility();
335
-
336
- // Fullscreen change
337
- document.addEventListener('fullscreenchange', () => this._onFullscreenChange());
338
- document.addEventListener('webkitfullscreenchange', () => this._onFullscreenChange());
339
- }
340
-
341
- _bindProgressEvents() {
342
- const progress = this.els.progressContainer;
343
- if (!progress) return;
344
-
345
- let isDragging = false;
346
-
347
- const seek = (e) => {
348
- const rect = this.els.progressBar.getBoundingClientRect();
349
- const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
350
- this.seekPercent(percent * 100);
351
- };
352
-
353
- const updatePreview = (e) => {
354
- const rect = this.els.progressBar.getBoundingClientRect();
355
- const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
356
- const time = percent * this.video.duration;
357
-
358
- if (this.els.previewTime) {
359
- this.els.previewTime.textContent = Utils.formatTime(time);
360
- }
361
- if (this.els.progressPreview) {
362
- const left = Utils.clamp(e.clientX - rect.left, 30, rect.width - 30);
363
- this.els.progressPreview.style.left = `${left}px`;
364
- }
365
- };
366
-
367
- progress.addEventListener('mousedown', (e) => {
368
- isDragging = true;
369
- seek(e);
370
- });
371
-
372
- progress.addEventListener('mousemove', (e) => {
373
- updatePreview(e);
374
- if (isDragging) seek(e);
375
- });
376
-
377
- document.addEventListener('mouseup', () => {
378
- isDragging = false;
379
- });
380
-
381
- document.addEventListener('mousemove', (e) => {
382
- if (isDragging) seek(e);
383
- });
384
- }
385
-
386
- _bindVolumeEvents() {
387
- const volumeTrack = this.els.volumeTrack;
388
- if (!volumeTrack) return;
389
-
390
- let isDragging = false;
391
-
392
- const setVolume = (e) => {
393
- const rect = volumeTrack.getBoundingClientRect();
394
- const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
395
- this.setVolume(percent);
396
- };
397
-
398
- volumeTrack.addEventListener('mousedown', (e) => {
399
- isDragging = true;
400
- setVolume(e);
401
- });
402
-
403
- document.addEventListener('mousemove', (e) => {
404
- if (isDragging) setVolume(e);
405
- });
406
-
407
- document.addEventListener('mouseup', () => {
408
- isDragging = false;
409
- });
410
- }
411
-
412
- _bindControlsVisibility() {
413
- const showControls = () => {
414
- this.container.classList.add('plex-controls-visible');
415
- clearTimeout(this._controlsTimeout);
416
- if (this._state.playing) {
417
- this._controlsTimeout = setTimeout(() => {
418
- this.container.classList.remove('plex-controls-visible');
419
- }, this.options.controlsHideDelay);
420
- }
421
- };
422
-
423
- this.container.addEventListener('mousemove', showControls);
424
- this.container.addEventListener('mouseenter', showControls);
425
- this.container.addEventListener('mouseleave', () => {
426
- if (this._state.playing) {
427
- this.container.classList.remove('plex-controls-visible');
428
- }
429
- });
430
-
431
- // Always show when paused
432
- this.video.addEventListener('pause', showControls);
433
- }
434
-
435
- // Event handlers
436
- _onPlay() {
437
- this._state.playing = true;
438
- this._state.paused = false;
439
- this.container.classList.add('plex-playing');
440
- this.container.classList.remove('plex-paused');
441
- this.bigPlayBtn.style.display = 'none';
442
- this._emit('play');
443
- }
444
-
445
- _onPause() {
446
- this._state.playing = false;
447
- this._state.paused = true;
448
- this.container.classList.remove('plex-playing');
449
- this.container.classList.add('plex-paused');
450
- this.bigPlayBtn.style.display = '';
451
- this._emit('pause');
452
- }
453
-
454
- _onEnded() {
455
- this._state.playing = false;
456
- this.container.classList.remove('plex-playing');
457
-
458
- // Auto-play next if playlist
459
- if (this._playlist.length > 0 && this._currentIndex < this._playlist.length - 1) {
460
- this.next();
461
- } else {
462
- this._emit('ended');
463
- }
464
- }
465
-
466
- _onTimeUpdate() {
467
- this._state.currentTime = this.video.currentTime;
468
- const percent = (this.video.currentTime / this.video.duration) * 100;
469
-
470
- if (this.els.progressPlayed) {
471
- this.els.progressPlayed.style.width = `${percent}%`;
472
- }
473
- if (this.els.progressHandle) {
474
- this.els.progressHandle.style.left = `${percent}%`;
475
- }
476
- if (this.els.timeCurrent) {
477
- this.els.timeCurrent.textContent = Utils.formatTime(this.video.currentTime);
478
- }
479
-
480
- this._emit('timeupdate', {
481
- currentTime: this.video.currentTime,
482
- duration: this.video.duration
483
- });
484
- }
485
-
486
- _onProgress() {
487
- const buffered = this.video.buffered;
488
- if (buffered.length > 0) {
489
- const percent = (buffered.end(buffered.length - 1) / this.video.duration) * 100;
490
- this._state.buffered = percent;
491
- if (this.els.progressBuffered) {
492
- this.els.progressBuffered.style.width = `${percent}%`;
493
- }
494
- this._emit('progress', { buffered: percent });
495
- }
496
- }
497
-
498
- _onLoadedMetadata() {
499
- this._state.duration = this.video.duration;
500
- if (this.els.timeDuration) {
501
- this.els.timeDuration.textContent = Utils.formatTime(this.video.duration);
502
- }
503
- this._emit('loadedmetadata', { duration: this.video.duration });
504
- }
505
-
506
- _onVolumeChange() {
507
- this._state.volume = this.video.volume;
508
- this._state.muted = this.video.muted;
509
-
510
- const volume = this.video.muted ? 0 : this.video.volume;
511
-
512
- // Update volume UI
513
- if (this.els.volumeLevel) {
514
- this.els.volumeLevel.style.width = `${volume * 100}%`;
515
- }
516
- if (this.els.volumeHandle) {
517
- this.els.volumeHandle.style.left = `${volume * 100}%`;
518
- }
519
-
520
- // Update icon
521
- this.container.classList.remove('plex-muted', 'plex-volume-low');
522
- if (this.video.muted || volume === 0) {
523
- this.container.classList.add('plex-muted');
524
- } else if (volume < 0.5) {
525
- this.container.classList.add('plex-volume-low');
526
- }
527
-
528
- this._emit('volumechange', { volume: this.video.volume, muted: this.video.muted });
529
- }
530
-
531
- _onWaiting() {
532
- this.container.classList.add('plex-loading');
533
- this._emit('waiting');
534
- }
535
-
536
- _onCanPlay() {
537
- this.container.classList.remove('plex-loading');
538
- this._emit('canplay');
539
- }
540
-
541
- _onError(e) {
542
- this._emit('error', {
543
- code: this.video.error?.code,
544
- message: this.video.error?.message
545
- });
546
- }
547
-
548
- _onFullscreenChange() {
549
- const isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
550
- this._state.fullscreen = isFullscreen;
551
-
552
- if (isFullscreen) {
553
- this.container.classList.add('plex-fullscreen');
554
- } else {
555
- this.container.classList.remove('plex-fullscreen');
556
- }
557
-
558
- this._emit('fullscreenchange', { isFullscreen });
559
- }
560
-
561
- // Keyboard support
562
- _initKeyboard() {
563
- this._keyHandler = (e) => {
564
- if (!this.container.contains(document.activeElement) &&
565
- document.activeElement !== document.body) return;
566
-
567
- switch (e.key) {
568
- case ' ':
569
- case 'k':
570
- e.preventDefault();
571
- this.togglePlay();
572
- break;
573
- case 'ArrowLeft':
574
- e.preventDefault();
575
- this.seek(this.video.currentTime - 10);
576
- break;
577
- case 'ArrowRight':
578
- e.preventDefault();
579
- this.seek(this.video.currentTime + 10);
580
- break;
581
- case 'ArrowUp':
582
- e.preventDefault();
583
- this.setVolume(Math.min(1, this.video.volume + 0.1));
584
- break;
585
- case 'ArrowDown':
586
- e.preventDefault();
587
- this.setVolume(Math.max(0, this.video.volume - 0.1));
588
- break;
589
- case 'm':
590
- this.toggleMute();
591
- break;
592
- case 'f':
593
- this.toggleFullscreen();
594
- break;
595
- case 'p':
596
- this.togglePiP();
597
- break;
598
- case 'n':
599
- if (e.shiftKey) {
600
- this.previous();
601
- } else {
602
- this.next();
603
- }
604
- break;
605
- }
606
- };
607
- document.addEventListener('keydown', this._keyHandler);
608
- }
609
-
610
- // Touch support
611
- _initTouch() {
612
- let touchStartX = 0;
613
- let touchStartY = 0;
614
- let touchStartTime = 0;
615
-
616
- this.video.addEventListener('touchstart', (e) => {
617
- touchStartX = e.touches[0].clientX;
618
- touchStartY = e.touches[0].clientY;
619
- touchStartTime = Date.now();
620
- });
621
-
622
- this.video.addEventListener('touchend', (e) => {
623
- const touchEndX = e.changedTouches[0].clientX;
624
- const touchEndY = e.changedTouches[0].clientY;
625
- const touchDuration = Date.now() - touchStartTime;
626
-
627
- const deltaX = touchEndX - touchStartX;
628
- const deltaY = touchEndY - touchStartY;
629
-
630
- // Tap to toggle play (if quick tap)
631
- if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && touchDuration < 300) {
632
- this.togglePlay();
633
- }
634
- // Swipe to seek
635
- else if (Math.abs(deltaX) > 50 && Math.abs(deltaY) < 30) {
636
- if (deltaX > 0) {
637
- this.seek(this.video.currentTime + 10);
638
- } else {
639
- this.seek(this.video.currentTime - 10);
640
- }
641
- }
642
- });
643
- }
644
-
645
- // PiP support
646
- _initPiP() {
647
- if (!document.pictureInPictureEnabled) {
648
- if (this.els.pipBtn) {
649
- this.els.pipBtn.style.display = 'none';
650
- }
651
- return;
652
- }
653
-
654
- this.video.addEventListener('enterpictureinpicture', () => {
655
- this._state.pip = true;
656
- this.container.classList.add('plex-pip');
657
- this._emit('enterpip');
658
- });
659
-
660
- this.video.addEventListener('leavepictureinpicture', () => {
661
- this._state.pip = false;
662
- this.container.classList.remove('plex-pip');
663
- this._emit('leavepip');
664
- });
665
- }
666
-
667
- // Cast support
668
- _initCast() {
669
- // Chromecast requires HTTPS and specific browser
670
- const isChrome = /Chrome/.test(navigator.userAgent) && !/Edge/.test(navigator.userAgent);
671
- const isHTTPS = location.protocol === 'https:' || location.hostname === 'localhost';
672
-
673
- if (!isChrome || !isHTTPS) {
674
- if (this.els.castBtn) {
675
- this.els.castBtn.style.display = 'none';
676
- }
677
- return;
678
- }
679
-
680
- // Load Cast SDK if available
681
- if (window.chrome && window.chrome.cast) {
682
- this._initCastApi();
683
- } else {
684
- window['__onGCastApiAvailable'] = (isAvailable) => {
685
- if (isAvailable) this._initCastApi();
686
- };
687
- }
688
- }
689
-
690
- _initCastApi() {
691
- if (this.els.castBtn) {
692
- this.els.castBtn.addEventListener('click', () => {
693
- if (window.cast && window.cast.framework) {
694
- const context = cast.framework.CastContext.getInstance();
695
- context.requestSession();
696
- }
697
- });
698
- }
699
- }
700
-
701
- // Event system
702
- on(event, callback) {
703
- if (!this._eventListeners.has(event)) {
704
- this._eventListeners.set(event, new Set());
705
- }
706
- this._eventListeners.get(event).add(callback);
707
- return this;
708
- }
709
-
710
- off(event, callback) {
711
- if (this._eventListeners.has(event)) {
712
- this._eventListeners.get(event).delete(callback);
713
- }
714
- return this;
715
- }
716
-
717
- _emit(event, data = {}) {
718
- if (this._eventListeners.has(event)) {
719
- this._eventListeners.get(event).forEach(callback => {
720
- try {
721
- callback(data);
722
- } catch (e) {
723
- console.error(`PlexPlayer: Error in ${event} handler:`, e);
724
- }
725
- });
726
- }
727
- }
728
-
729
- // Public API
730
- load(src, poster) {
731
- this.video.src = src;
732
- if (poster) this.video.poster = poster;
733
- this.video.load();
734
-
735
- if (this.options.autoplay) {
736
- this.play();
737
- }
738
- return this;
739
- }
740
-
741
- loadPlaylist(items) {
742
- this._playlist = items.map((item, index) => ({
743
- ...item,
744
- index
745
- }));
746
- this._currentIndex = 0;
747
-
748
- if (this._playlist.length > 0) {
749
- const first = this._playlist[0];
750
- this.load(first.src, first.poster);
751
- this._emit('playlistload', { playlist: this._playlist });
752
- }
753
- return this;
754
- }
755
-
756
- play() {
757
- return this.video.play();
758
- }
759
-
760
- pause() {
761
- this.video.pause();
762
- return this;
763
- }
764
-
765
- togglePlay() {
766
- if (this.video.paused) {
767
- this.play();
768
- } else {
769
- this.pause();
770
- }
771
- return this;
772
- }
773
-
774
- seek(time) {
775
- this.video.currentTime = Utils.clamp(time, 0, this.video.duration || 0);
776
- return this;
777
- }
778
-
779
- seekPercent(percent) {
780
- const time = (percent / 100) * this.video.duration;
781
- return this.seek(time);
782
- }
783
-
784
- setVolume(level) {
785
- this.video.volume = Utils.clamp(level, 0, 1);
786
- if (this.video.muted && level > 0) {
787
- this.video.muted = false;
788
- }
789
- return this;
790
- }
791
-
792
- getVolume() {
793
- return this.video.volume;
794
- }
795
-
796
- mute() {
797
- this.video.muted = true;
798
- return this;
799
- }
800
-
801
- unmute() {
802
- this.video.muted = false;
803
- return this;
804
- }
805
-
806
- toggleMute() {
807
- this.video.muted = !this.video.muted;
808
- return this;
809
- }
810
-
811
- setPlaybackRate(rate) {
812
- this.video.playbackRate = rate;
813
- this._state.playbackRate = rate;
814
- this._emit('ratechange', { rate });
815
- return this;
816
- }
817
-
818
- getPlaybackRate() {
819
- return this.video.playbackRate;
820
- }
821
-
822
- enterFullscreen() {
823
- const el = this.container;
824
- if (el.requestFullscreen) {
825
- return el.requestFullscreen();
826
- } else if (el.webkitRequestFullscreen) {
827
- return el.webkitRequestFullscreen();
828
- }
829
- }
830
-
831
- exitFullscreen() {
832
- if (document.exitFullscreen) {
833
- return document.exitFullscreen();
834
- } else if (document.webkitExitFullscreen) {
835
- return document.webkitExitFullscreen();
836
- }
837
- }
838
-
839
- toggleFullscreen() {
840
- if (this._state.fullscreen) {
841
- this.exitFullscreen();
842
- } else {
843
- this.enterFullscreen();
844
- }
845
- return this;
846
- }
847
-
848
- enterPiP() {
849
- if (document.pictureInPictureEnabled && this.video !== document.pictureInPictureElement) {
850
- return this.video.requestPictureInPicture();
851
- }
852
- }
853
-
854
- exitPiP() {
855
- if (document.pictureInPictureElement) {
856
- return document.exitPictureInPicture();
857
- }
858
- }
859
-
860
- togglePiP() {
861
- if (this._state.pip) {
862
- this.exitPiP();
863
- } else {
864
- this.enterPiP();
865
- }
866
- return this;
867
- }
868
-
869
- next() {
870
- if (this._playlist.length > 0 && this._currentIndex < this._playlist.length - 1) {
871
- this._currentIndex++;
872
- const item = this._playlist[this._currentIndex];
873
- this.load(item.src, item.poster);
874
- this.play();
875
- this._emit('trackchange', { index: this._currentIndex, item });
876
- }
877
- return this;
878
- }
879
-
880
- previous() {
881
- if (this._playlist.length > 0 && this._currentIndex > 0) {
882
- this._currentIndex--;
883
- const item = this._playlist[this._currentIndex];
884
- this.load(item.src, item.poster);
885
- this.play();
886
- this._emit('trackchange', { index: this._currentIndex, item });
887
- }
888
- return this;
889
- }
890
-
891
- playAt(index) {
892
- if (this._playlist.length > 0 && index >= 0 && index < this._playlist.length) {
893
- this._currentIndex = index;
894
- const item = this._playlist[this._currentIndex];
895
- this.load(item.src, item.poster);
896
- this.play();
897
- this._emit('trackchange', { index: this._currentIndex, item });
898
- }
899
- return this;
900
- }
901
-
902
- getState() {
903
- return {
904
- currentTime: this.video.currentTime,
905
- duration: this.video.duration || 0,
906
- volume: this.video.volume,
907
- muted: this.video.muted,
908
- isPlaying: !this.video.paused && !this.video.ended,
909
- isPaused: this.video.paused,
910
- isFullscreen: this._state.fullscreen,
911
- isPiP: this._state.pip,
912
- playbackRate: this.video.playbackRate,
913
- currentTrack: this._currentIndex,
914
- totalTracks: this._playlist.length,
915
- };
916
- }
917
-
918
- getVideo() {
919
- return this.video;
920
- }
921
-
922
- destroy() {
923
- // Clear timeouts
924
- clearTimeout(this._controlsTimeout);
925
-
926
- // Remove keyboard listener
927
- if (this._keyHandler) {
928
- document.removeEventListener('keydown', this._keyHandler);
929
- }
930
-
931
- // Remove event listeners
932
- this._eventListeners.clear();
933
-
934
- // Clear container
935
- this.container.innerHTML = '';
936
- this.container.classList.remove('plex-player');
937
-
938
- // Emit destroy event
939
- this._emit('destroy');
940
- }
941
- }
942
-
943
- // Export
944
- export { PlexPlayer, Utils };
945
- export default PlexPlayer;
946
-
947
- // Attach to window for UMD builds
948
- if (typeof window !== 'undefined') {
949
- window.PlexPlayer = PlexPlayer;
950
- }