@frameset/plex-player 1.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.
@@ -0,0 +1,924 @@
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
+ // Internal state
89
+ this._eventListeners = new Map();
90
+ this._state = {
91
+ playing: false,
92
+ paused: true,
93
+ muted: this.options.muted,
94
+ volume: this.options.volume,
95
+ currentTime: 0,
96
+ duration: 0,
97
+ buffered: 0,
98
+ fullscreen: false,
99
+ pip: false,
100
+ playbackRate: 1,
101
+ };
102
+
103
+ // Playlist state
104
+ this._playlist = [];
105
+ this._currentIndex = 0;
106
+
107
+ // Controls timeout
108
+ this._controlsTimeout = null;
109
+
110
+ // Initialize player
111
+ this._init();
112
+ }
113
+
114
+ _init() {
115
+ // Add player class
116
+ this.container.classList.add('plex-player');
117
+
118
+ // Apply theme
119
+ this._applyTheme();
120
+
121
+ // Create DOM structure
122
+ this._createDOM();
123
+
124
+ // Bind events
125
+ this._bindEvents();
126
+
127
+ // Initialize features
128
+ if (this.options.keyboard) this._initKeyboard();
129
+ if (this.options.touch) this._initTouch();
130
+ if (this.options.pip) this._initPiP();
131
+ if (this.options.cast) this._initCast();
132
+
133
+ // Emit ready event
134
+ setTimeout(() => this._emit('ready'), 0);
135
+ }
136
+
137
+ _applyTheme() {
138
+ const { theme } = this.options;
139
+ if (theme.primary) this.container.style.setProperty('--plex-primary', theme.primary);
140
+ if (theme.background) this.container.style.setProperty('--plex-bg', theme.background);
141
+ if (theme.text) this.container.style.setProperty('--plex-text', theme.text);
142
+ if (theme.borderRadius) this.container.style.setProperty('--plex-border-radius', theme.borderRadius);
143
+ }
144
+
145
+ _createDOM() {
146
+ // Video element
147
+ this.video = document.createElement('video');
148
+ this.video.className = 'plex-video';
149
+ this.video.setAttribute('playsinline', '');
150
+ this.video.setAttribute('webkit-playsinline', '');
151
+ this.video.preload = this.options.preload;
152
+ this.video.muted = this.options.muted;
153
+ this.video.volume = this.options.volume;
154
+ this.video.loop = this.options.loop;
155
+ if (this.options.poster) this.video.poster = this.options.poster;
156
+ this.container.appendChild(this.video);
157
+
158
+ // Controls overlay
159
+ this.controls = document.createElement('div');
160
+ this.controls.className = 'plex-controls';
161
+ this.controls.innerHTML = this._getControlsHTML();
162
+ this.container.appendChild(this.controls);
163
+
164
+ // Cache control elements
165
+ this._cacheElements();
166
+
167
+ // Big play button
168
+ this.bigPlayBtn = document.createElement('div');
169
+ this.bigPlayBtn.className = 'plex-big-play';
170
+ this.bigPlayBtn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`;
171
+ this.container.appendChild(this.bigPlayBtn);
172
+
173
+ // Loading spinner
174
+ this.loader = document.createElement('div');
175
+ this.loader.className = 'plex-loader';
176
+ this.loader.innerHTML = '<div class="plex-spinner"></div>';
177
+ this.container.appendChild(this.loader);
178
+ }
179
+
180
+ _getControlsHTML() {
181
+ const { i18n } = this.options;
182
+ return `
183
+ <div class="plex-progress-container">
184
+ <div class="plex-progress-bar">
185
+ <div class="plex-progress-buffered"></div>
186
+ <div class="plex-progress-played"></div>
187
+ <div class="plex-progress-handle"></div>
188
+ </div>
189
+ <div class="plex-progress-preview">
190
+ <div class="plex-preview-time">0:00</div>
191
+ </div>
192
+ </div>
193
+ <div class="plex-controls-bar">
194
+ <div class="plex-controls-left">
195
+ <button class="plex-btn plex-play-btn" title="${i18n.play || 'Play'}">
196
+ <svg class="plex-icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
197
+ <svg class="plex-icon-pause" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
198
+ </button>
199
+ <button class="plex-btn plex-prev-btn" title="${i18n.previous || 'Previous'}">
200
+ <svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
201
+ </button>
202
+ <button class="plex-btn plex-next-btn" title="${i18n.next || 'Next'}">
203
+ <svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
204
+ </button>
205
+ <div class="plex-volume-container">
206
+ <button class="plex-btn plex-volume-btn" title="${i18n.mute || 'Mute'}">
207
+ <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>
208
+ <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>
209
+ <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>
210
+ </button>
211
+ <div class="plex-volume-slider">
212
+ <div class="plex-volume-track">
213
+ <div class="plex-volume-level"></div>
214
+ <div class="plex-volume-handle"></div>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ <div class="plex-time">
219
+ <span class="plex-time-current">0:00</span>
220
+ <span class="plex-time-separator">/</span>
221
+ <span class="plex-time-duration">0:00</span>
222
+ </div>
223
+ </div>
224
+ <div class="plex-controls-right">
225
+ <button class="plex-btn plex-settings-btn" title="${i18n.settings || 'Settings'}">
226
+ <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>
227
+ </button>
228
+ ${this.options.pip ? `
229
+ <button class="plex-btn plex-pip-btn" title="${i18n.pip || 'Picture-in-Picture'}">
230
+ <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>
231
+ </button>
232
+ ` : ''}
233
+ ${this.options.cast ? `
234
+ <button class="plex-btn plex-cast-btn" title="${i18n.cast || 'Cast'}">
235
+ <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>
236
+ </button>
237
+ ` : ''}
238
+ ${this.options.fullscreen ? `
239
+ <button class="plex-btn plex-fullscreen-btn" title="${i18n.fullscreen || 'Fullscreen'}">
240
+ <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>
241
+ <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>
242
+ </button>
243
+ ` : ''}
244
+ </div>
245
+ </div>
246
+ `;
247
+ }
248
+
249
+ _cacheElements() {
250
+ const $ = (sel) => this.controls.querySelector(sel);
251
+
252
+ this.els = {
253
+ progressContainer: $('.plex-progress-container'),
254
+ progressBar: $('.plex-progress-bar'),
255
+ progressBuffered: $('.plex-progress-buffered'),
256
+ progressPlayed: $('.plex-progress-played'),
257
+ progressHandle: $('.plex-progress-handle'),
258
+ progressPreview: $('.plex-progress-preview'),
259
+ previewTime: $('.plex-preview-time'),
260
+ playBtn: $('.plex-play-btn'),
261
+ prevBtn: $('.plex-prev-btn'),
262
+ nextBtn: $('.plex-next-btn'),
263
+ volumeBtn: $('.plex-volume-btn'),
264
+ volumeSlider: $('.plex-volume-slider'),
265
+ volumeTrack: $('.plex-volume-track'),
266
+ volumeLevel: $('.plex-volume-level'),
267
+ volumeHandle: $('.plex-volume-handle'),
268
+ timeCurrent: $('.plex-time-current'),
269
+ timeDuration: $('.plex-time-duration'),
270
+ settingsBtn: $('.plex-settings-btn'),
271
+ pipBtn: $('.plex-pip-btn'),
272
+ castBtn: $('.plex-cast-btn'),
273
+ fullscreenBtn: $('.plex-fullscreen-btn'),
274
+ };
275
+ }
276
+
277
+ _bindEvents() {
278
+ // Video events
279
+ this.video.addEventListener('play', () => this._onPlay());
280
+ this.video.addEventListener('pause', () => this._onPause());
281
+ this.video.addEventListener('ended', () => this._onEnded());
282
+ this.video.addEventListener('timeupdate', () => this._onTimeUpdate());
283
+ this.video.addEventListener('progress', () => this._onProgress());
284
+ this.video.addEventListener('loadedmetadata', () => this._onLoadedMetadata());
285
+ this.video.addEventListener('volumechange', () => this._onVolumeChange());
286
+ this.video.addEventListener('waiting', () => this._onWaiting());
287
+ this.video.addEventListener('canplay', () => this._onCanPlay());
288
+ this.video.addEventListener('error', (e) => this._onError(e));
289
+
290
+ // Control events
291
+ this.bigPlayBtn.addEventListener('click', () => this.togglePlay());
292
+ this.video.addEventListener('click', () => this.togglePlay());
293
+
294
+ this.els.playBtn?.addEventListener('click', () => this.togglePlay());
295
+ this.els.prevBtn?.addEventListener('click', () => this.previous());
296
+ this.els.nextBtn?.addEventListener('click', () => this.next());
297
+ this.els.volumeBtn?.addEventListener('click', () => this.toggleMute());
298
+ this.els.pipBtn?.addEventListener('click', () => this.togglePiP());
299
+ this.els.fullscreenBtn?.addEventListener('click', () => this.toggleFullscreen());
300
+
301
+ // Progress bar
302
+ this._bindProgressEvents();
303
+
304
+ // Volume slider
305
+ this._bindVolumeEvents();
306
+
307
+ // Controls visibility
308
+ this._bindControlsVisibility();
309
+
310
+ // Fullscreen change
311
+ document.addEventListener('fullscreenchange', () => this._onFullscreenChange());
312
+ document.addEventListener('webkitfullscreenchange', () => this._onFullscreenChange());
313
+ }
314
+
315
+ _bindProgressEvents() {
316
+ const progress = this.els.progressContainer;
317
+ if (!progress) return;
318
+
319
+ let isDragging = false;
320
+
321
+ const seek = (e) => {
322
+ const rect = this.els.progressBar.getBoundingClientRect();
323
+ const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
324
+ this.seekPercent(percent * 100);
325
+ };
326
+
327
+ const updatePreview = (e) => {
328
+ const rect = this.els.progressBar.getBoundingClientRect();
329
+ const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
330
+ const time = percent * this.video.duration;
331
+
332
+ if (this.els.previewTime) {
333
+ this.els.previewTime.textContent = Utils.formatTime(time);
334
+ }
335
+ if (this.els.progressPreview) {
336
+ const left = Utils.clamp(e.clientX - rect.left, 30, rect.width - 30);
337
+ this.els.progressPreview.style.left = `${left}px`;
338
+ }
339
+ };
340
+
341
+ progress.addEventListener('mousedown', (e) => {
342
+ isDragging = true;
343
+ seek(e);
344
+ });
345
+
346
+ progress.addEventListener('mousemove', (e) => {
347
+ updatePreview(e);
348
+ if (isDragging) seek(e);
349
+ });
350
+
351
+ document.addEventListener('mouseup', () => {
352
+ isDragging = false;
353
+ });
354
+
355
+ document.addEventListener('mousemove', (e) => {
356
+ if (isDragging) seek(e);
357
+ });
358
+ }
359
+
360
+ _bindVolumeEvents() {
361
+ const volumeTrack = this.els.volumeTrack;
362
+ if (!volumeTrack) return;
363
+
364
+ let isDragging = false;
365
+
366
+ const setVolume = (e) => {
367
+ const rect = volumeTrack.getBoundingClientRect();
368
+ const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
369
+ this.setVolume(percent);
370
+ };
371
+
372
+ volumeTrack.addEventListener('mousedown', (e) => {
373
+ isDragging = true;
374
+ setVolume(e);
375
+ });
376
+
377
+ document.addEventListener('mousemove', (e) => {
378
+ if (isDragging) setVolume(e);
379
+ });
380
+
381
+ document.addEventListener('mouseup', () => {
382
+ isDragging = false;
383
+ });
384
+ }
385
+
386
+ _bindControlsVisibility() {
387
+ const showControls = () => {
388
+ this.container.classList.add('plex-controls-visible');
389
+ clearTimeout(this._controlsTimeout);
390
+ if (this._state.playing) {
391
+ this._controlsTimeout = setTimeout(() => {
392
+ this.container.classList.remove('plex-controls-visible');
393
+ }, this.options.controlsHideDelay);
394
+ }
395
+ };
396
+
397
+ this.container.addEventListener('mousemove', showControls);
398
+ this.container.addEventListener('mouseenter', showControls);
399
+ this.container.addEventListener('mouseleave', () => {
400
+ if (this._state.playing) {
401
+ this.container.classList.remove('plex-controls-visible');
402
+ }
403
+ });
404
+
405
+ // Always show when paused
406
+ this.video.addEventListener('pause', showControls);
407
+ }
408
+
409
+ // Event handlers
410
+ _onPlay() {
411
+ this._state.playing = true;
412
+ this._state.paused = false;
413
+ this.container.classList.add('plex-playing');
414
+ this.container.classList.remove('plex-paused');
415
+ this.bigPlayBtn.style.display = 'none';
416
+ this._emit('play');
417
+ }
418
+
419
+ _onPause() {
420
+ this._state.playing = false;
421
+ this._state.paused = true;
422
+ this.container.classList.remove('plex-playing');
423
+ this.container.classList.add('plex-paused');
424
+ this.bigPlayBtn.style.display = '';
425
+ this._emit('pause');
426
+ }
427
+
428
+ _onEnded() {
429
+ this._state.playing = false;
430
+ this.container.classList.remove('plex-playing');
431
+
432
+ // Auto-play next if playlist
433
+ if (this._playlist.length > 0 && this._currentIndex < this._playlist.length - 1) {
434
+ this.next();
435
+ } else {
436
+ this._emit('ended');
437
+ }
438
+ }
439
+
440
+ _onTimeUpdate() {
441
+ this._state.currentTime = this.video.currentTime;
442
+ const percent = (this.video.currentTime / this.video.duration) * 100;
443
+
444
+ if (this.els.progressPlayed) {
445
+ this.els.progressPlayed.style.width = `${percent}%`;
446
+ }
447
+ if (this.els.progressHandle) {
448
+ this.els.progressHandle.style.left = `${percent}%`;
449
+ }
450
+ if (this.els.timeCurrent) {
451
+ this.els.timeCurrent.textContent = Utils.formatTime(this.video.currentTime);
452
+ }
453
+
454
+ this._emit('timeupdate', {
455
+ currentTime: this.video.currentTime,
456
+ duration: this.video.duration
457
+ });
458
+ }
459
+
460
+ _onProgress() {
461
+ const buffered = this.video.buffered;
462
+ if (buffered.length > 0) {
463
+ const percent = (buffered.end(buffered.length - 1) / this.video.duration) * 100;
464
+ this._state.buffered = percent;
465
+ if (this.els.progressBuffered) {
466
+ this.els.progressBuffered.style.width = `${percent}%`;
467
+ }
468
+ this._emit('progress', { buffered: percent });
469
+ }
470
+ }
471
+
472
+ _onLoadedMetadata() {
473
+ this._state.duration = this.video.duration;
474
+ if (this.els.timeDuration) {
475
+ this.els.timeDuration.textContent = Utils.formatTime(this.video.duration);
476
+ }
477
+ this._emit('loadedmetadata', { duration: this.video.duration });
478
+ }
479
+
480
+ _onVolumeChange() {
481
+ this._state.volume = this.video.volume;
482
+ this._state.muted = this.video.muted;
483
+
484
+ const volume = this.video.muted ? 0 : this.video.volume;
485
+
486
+ // Update volume UI
487
+ if (this.els.volumeLevel) {
488
+ this.els.volumeLevel.style.width = `${volume * 100}%`;
489
+ }
490
+ if (this.els.volumeHandle) {
491
+ this.els.volumeHandle.style.left = `${volume * 100}%`;
492
+ }
493
+
494
+ // Update icon
495
+ this.container.classList.remove('plex-muted', 'plex-volume-low');
496
+ if (this.video.muted || volume === 0) {
497
+ this.container.classList.add('plex-muted');
498
+ } else if (volume < 0.5) {
499
+ this.container.classList.add('plex-volume-low');
500
+ }
501
+
502
+ this._emit('volumechange', { volume: this.video.volume, muted: this.video.muted });
503
+ }
504
+
505
+ _onWaiting() {
506
+ this.container.classList.add('plex-loading');
507
+ this._emit('waiting');
508
+ }
509
+
510
+ _onCanPlay() {
511
+ this.container.classList.remove('plex-loading');
512
+ this._emit('canplay');
513
+ }
514
+
515
+ _onError(e) {
516
+ this._emit('error', {
517
+ code: this.video.error?.code,
518
+ message: this.video.error?.message
519
+ });
520
+ }
521
+
522
+ _onFullscreenChange() {
523
+ const isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
524
+ this._state.fullscreen = isFullscreen;
525
+
526
+ if (isFullscreen) {
527
+ this.container.classList.add('plex-fullscreen');
528
+ } else {
529
+ this.container.classList.remove('plex-fullscreen');
530
+ }
531
+
532
+ this._emit('fullscreenchange', { isFullscreen });
533
+ }
534
+
535
+ // Keyboard support
536
+ _initKeyboard() {
537
+ this._keyHandler = (e) => {
538
+ if (!this.container.contains(document.activeElement) &&
539
+ document.activeElement !== document.body) return;
540
+
541
+ switch (e.key) {
542
+ case ' ':
543
+ case 'k':
544
+ e.preventDefault();
545
+ this.togglePlay();
546
+ break;
547
+ case 'ArrowLeft':
548
+ e.preventDefault();
549
+ this.seek(this.video.currentTime - 10);
550
+ break;
551
+ case 'ArrowRight':
552
+ e.preventDefault();
553
+ this.seek(this.video.currentTime + 10);
554
+ break;
555
+ case 'ArrowUp':
556
+ e.preventDefault();
557
+ this.setVolume(Math.min(1, this.video.volume + 0.1));
558
+ break;
559
+ case 'ArrowDown':
560
+ e.preventDefault();
561
+ this.setVolume(Math.max(0, this.video.volume - 0.1));
562
+ break;
563
+ case 'm':
564
+ this.toggleMute();
565
+ break;
566
+ case 'f':
567
+ this.toggleFullscreen();
568
+ break;
569
+ case 'p':
570
+ this.togglePiP();
571
+ break;
572
+ case 'n':
573
+ if (e.shiftKey) {
574
+ this.previous();
575
+ } else {
576
+ this.next();
577
+ }
578
+ break;
579
+ }
580
+ };
581
+ document.addEventListener('keydown', this._keyHandler);
582
+ }
583
+
584
+ // Touch support
585
+ _initTouch() {
586
+ let touchStartX = 0;
587
+ let touchStartY = 0;
588
+ let touchStartTime = 0;
589
+
590
+ this.video.addEventListener('touchstart', (e) => {
591
+ touchStartX = e.touches[0].clientX;
592
+ touchStartY = e.touches[0].clientY;
593
+ touchStartTime = Date.now();
594
+ });
595
+
596
+ this.video.addEventListener('touchend', (e) => {
597
+ const touchEndX = e.changedTouches[0].clientX;
598
+ const touchEndY = e.changedTouches[0].clientY;
599
+ const touchDuration = Date.now() - touchStartTime;
600
+
601
+ const deltaX = touchEndX - touchStartX;
602
+ const deltaY = touchEndY - touchStartY;
603
+
604
+ // Tap to toggle play (if quick tap)
605
+ if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && touchDuration < 300) {
606
+ this.togglePlay();
607
+ }
608
+ // Swipe to seek
609
+ else if (Math.abs(deltaX) > 50 && Math.abs(deltaY) < 30) {
610
+ if (deltaX > 0) {
611
+ this.seek(this.video.currentTime + 10);
612
+ } else {
613
+ this.seek(this.video.currentTime - 10);
614
+ }
615
+ }
616
+ });
617
+ }
618
+
619
+ // PiP support
620
+ _initPiP() {
621
+ if (!document.pictureInPictureEnabled) {
622
+ if (this.els.pipBtn) {
623
+ this.els.pipBtn.style.display = 'none';
624
+ }
625
+ return;
626
+ }
627
+
628
+ this.video.addEventListener('enterpictureinpicture', () => {
629
+ this._state.pip = true;
630
+ this.container.classList.add('plex-pip');
631
+ this._emit('enterpip');
632
+ });
633
+
634
+ this.video.addEventListener('leavepictureinpicture', () => {
635
+ this._state.pip = false;
636
+ this.container.classList.remove('plex-pip');
637
+ this._emit('leavepip');
638
+ });
639
+ }
640
+
641
+ // Cast support
642
+ _initCast() {
643
+ // Chromecast requires HTTPS and specific browser
644
+ const isChrome = /Chrome/.test(navigator.userAgent) && !/Edge/.test(navigator.userAgent);
645
+ const isHTTPS = location.protocol === 'https:' || location.hostname === 'localhost';
646
+
647
+ if (!isChrome || !isHTTPS) {
648
+ if (this.els.castBtn) {
649
+ this.els.castBtn.style.display = 'none';
650
+ }
651
+ return;
652
+ }
653
+
654
+ // Load Cast SDK if available
655
+ if (window.chrome && window.chrome.cast) {
656
+ this._initCastApi();
657
+ } else {
658
+ window['__onGCastApiAvailable'] = (isAvailable) => {
659
+ if (isAvailable) this._initCastApi();
660
+ };
661
+ }
662
+ }
663
+
664
+ _initCastApi() {
665
+ if (this.els.castBtn) {
666
+ this.els.castBtn.addEventListener('click', () => {
667
+ if (window.cast && window.cast.framework) {
668
+ const context = cast.framework.CastContext.getInstance();
669
+ context.requestSession();
670
+ }
671
+ });
672
+ }
673
+ }
674
+
675
+ // Event system
676
+ on(event, callback) {
677
+ if (!this._eventListeners.has(event)) {
678
+ this._eventListeners.set(event, new Set());
679
+ }
680
+ this._eventListeners.get(event).add(callback);
681
+ return this;
682
+ }
683
+
684
+ off(event, callback) {
685
+ if (this._eventListeners.has(event)) {
686
+ this._eventListeners.get(event).delete(callback);
687
+ }
688
+ return this;
689
+ }
690
+
691
+ _emit(event, data = {}) {
692
+ if (this._eventListeners.has(event)) {
693
+ this._eventListeners.get(event).forEach(callback => {
694
+ try {
695
+ callback(data);
696
+ } catch (e) {
697
+ console.error(`PlexPlayer: Error in ${event} handler:`, e);
698
+ }
699
+ });
700
+ }
701
+ }
702
+
703
+ // Public API
704
+ load(src, poster) {
705
+ this.video.src = src;
706
+ if (poster) this.video.poster = poster;
707
+ this.video.load();
708
+
709
+ if (this.options.autoplay) {
710
+ this.play();
711
+ }
712
+ return this;
713
+ }
714
+
715
+ loadPlaylist(items) {
716
+ this._playlist = items.map((item, index) => ({
717
+ ...item,
718
+ index
719
+ }));
720
+ this._currentIndex = 0;
721
+
722
+ if (this._playlist.length > 0) {
723
+ const first = this._playlist[0];
724
+ this.load(first.src, first.poster);
725
+ this._emit('playlistload', { playlist: this._playlist });
726
+ }
727
+ return this;
728
+ }
729
+
730
+ play() {
731
+ return this.video.play();
732
+ }
733
+
734
+ pause() {
735
+ this.video.pause();
736
+ return this;
737
+ }
738
+
739
+ togglePlay() {
740
+ if (this.video.paused) {
741
+ this.play();
742
+ } else {
743
+ this.pause();
744
+ }
745
+ return this;
746
+ }
747
+
748
+ seek(time) {
749
+ this.video.currentTime = Utils.clamp(time, 0, this.video.duration || 0);
750
+ return this;
751
+ }
752
+
753
+ seekPercent(percent) {
754
+ const time = (percent / 100) * this.video.duration;
755
+ return this.seek(time);
756
+ }
757
+
758
+ setVolume(level) {
759
+ this.video.volume = Utils.clamp(level, 0, 1);
760
+ if (this.video.muted && level > 0) {
761
+ this.video.muted = false;
762
+ }
763
+ return this;
764
+ }
765
+
766
+ getVolume() {
767
+ return this.video.volume;
768
+ }
769
+
770
+ mute() {
771
+ this.video.muted = true;
772
+ return this;
773
+ }
774
+
775
+ unmute() {
776
+ this.video.muted = false;
777
+ return this;
778
+ }
779
+
780
+ toggleMute() {
781
+ this.video.muted = !this.video.muted;
782
+ return this;
783
+ }
784
+
785
+ setPlaybackRate(rate) {
786
+ this.video.playbackRate = rate;
787
+ this._state.playbackRate = rate;
788
+ this._emit('ratechange', { rate });
789
+ return this;
790
+ }
791
+
792
+ getPlaybackRate() {
793
+ return this.video.playbackRate;
794
+ }
795
+
796
+ enterFullscreen() {
797
+ const el = this.container;
798
+ if (el.requestFullscreen) {
799
+ return el.requestFullscreen();
800
+ } else if (el.webkitRequestFullscreen) {
801
+ return el.webkitRequestFullscreen();
802
+ }
803
+ }
804
+
805
+ exitFullscreen() {
806
+ if (document.exitFullscreen) {
807
+ return document.exitFullscreen();
808
+ } else if (document.webkitExitFullscreen) {
809
+ return document.webkitExitFullscreen();
810
+ }
811
+ }
812
+
813
+ toggleFullscreen() {
814
+ if (this._state.fullscreen) {
815
+ this.exitFullscreen();
816
+ } else {
817
+ this.enterFullscreen();
818
+ }
819
+ return this;
820
+ }
821
+
822
+ enterPiP() {
823
+ if (document.pictureInPictureEnabled && this.video !== document.pictureInPictureElement) {
824
+ return this.video.requestPictureInPicture();
825
+ }
826
+ }
827
+
828
+ exitPiP() {
829
+ if (document.pictureInPictureElement) {
830
+ return document.exitPictureInPicture();
831
+ }
832
+ }
833
+
834
+ togglePiP() {
835
+ if (this._state.pip) {
836
+ this.exitPiP();
837
+ } else {
838
+ this.enterPiP();
839
+ }
840
+ return this;
841
+ }
842
+
843
+ next() {
844
+ if (this._playlist.length > 0 && this._currentIndex < this._playlist.length - 1) {
845
+ this._currentIndex++;
846
+ const item = this._playlist[this._currentIndex];
847
+ this.load(item.src, item.poster);
848
+ this.play();
849
+ this._emit('trackchange', { index: this._currentIndex, item });
850
+ }
851
+ return this;
852
+ }
853
+
854
+ previous() {
855
+ if (this._playlist.length > 0 && this._currentIndex > 0) {
856
+ this._currentIndex--;
857
+ const item = this._playlist[this._currentIndex];
858
+ this.load(item.src, item.poster);
859
+ this.play();
860
+ this._emit('trackchange', { index: this._currentIndex, item });
861
+ }
862
+ return this;
863
+ }
864
+
865
+ playAt(index) {
866
+ if (this._playlist.length > 0 && index >= 0 && index < this._playlist.length) {
867
+ this._currentIndex = index;
868
+ const item = this._playlist[this._currentIndex];
869
+ this.load(item.src, item.poster);
870
+ this.play();
871
+ this._emit('trackchange', { index: this._currentIndex, item });
872
+ }
873
+ return this;
874
+ }
875
+
876
+ getState() {
877
+ return {
878
+ currentTime: this.video.currentTime,
879
+ duration: this.video.duration || 0,
880
+ volume: this.video.volume,
881
+ muted: this.video.muted,
882
+ isPlaying: !this.video.paused && !this.video.ended,
883
+ isPaused: this.video.paused,
884
+ isFullscreen: this._state.fullscreen,
885
+ isPiP: this._state.pip,
886
+ playbackRate: this.video.playbackRate,
887
+ currentTrack: this._currentIndex,
888
+ totalTracks: this._playlist.length,
889
+ };
890
+ }
891
+
892
+ getVideo() {
893
+ return this.video;
894
+ }
895
+
896
+ destroy() {
897
+ // Clear timeouts
898
+ clearTimeout(this._controlsTimeout);
899
+
900
+ // Remove keyboard listener
901
+ if (this._keyHandler) {
902
+ document.removeEventListener('keydown', this._keyHandler);
903
+ }
904
+
905
+ // Remove event listeners
906
+ this._eventListeners.clear();
907
+
908
+ // Clear container
909
+ this.container.innerHTML = '';
910
+ this.container.classList.remove('plex-player');
911
+
912
+ // Emit destroy event
913
+ this._emit('destroy');
914
+ }
915
+ }
916
+
917
+ // Export
918
+ export { PlexPlayer, Utils };
919
+ export default PlexPlayer;
920
+
921
+ // Attach to window for UMD builds
922
+ if (typeof window !== 'undefined') {
923
+ window.PlexPlayer = PlexPlayer;
924
+ }